From bb7706288fc168949afc26919d795af805c6814a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 14:52:52 +0100 Subject: [PATCH 01/10] test(aangeboden-gebruik): golden for the koppelingen lookup by uuid, acting organisation pinned Assisted-by: ClaudeCode:claude-opus-5-5 --- ...AangebodenGebruikKoppelingenGoldenTest.php | 327 ++++ .../golden/aangebodenGebruikKoppelingen.json | 1369 +++++++++++++++++ 2 files changed, 1696 insertions(+) create mode 100644 tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php create mode 100644 tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json diff --git a/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php b/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php new file mode 100644 index 00000000..301f430a --- /dev/null +++ b/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php @@ -0,0 +1,327 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/aangeboden-gebruik-api/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Service\OrganisationService; +use OCA\Stackiq\Service\AangebodenGebruikService; +use OCA\Stackiq\Service\SettingsService; +use OCP\App\IAppManager; +use OCP\IAppConfig; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\AbstractLogger; + +/** + * Golden cases for the koppelingen and gebruik lookup by uuid. + * + * @covers \OCA\Stackiq\Service\AangebodenGebruikService + * + * @spec openspec/specs/aangeboden-gebruik-api/spec.md + */ +class AangebodenGebruikKoppelingenGoldenTest extends TestCase { + + /** + * The organisation of the signed-in user. + */ + private const ACTING_ORG = 'org-acting'; + + /** + * Everything the doubles saw, in order. + * + * @var array + */ + private array $trace = []; + + /** + * Per-case behaviour of the doubles. + * + * @var array + */ + private array $plan = []; + + /** + * The golden file. + * + * @return string + */ + private static function golden(): string { + return __DIR__ . '/golden/aangebodenGebruikKoppelingen.json'; + + }//end golden() + + /** + * The cases: uuid, options, ambtenaar flag and the behaviour of the doubles. + * + * @return array}> + */ + public static function cases(): array { + $module = ['owner' => self::ACTING_ORG, 'schema' => 'schema-module']; + $org = ['owner' => self::ACTING_ORG, 'schema' => 'schema-organisatie']; + return [ + 'empty uuid' => ['', [], true, []], + 'missing koppeling schema' => ['uuid-1', [], true, ['config' => ['register' => 'reg-1', 'gebruik_schema' => 'schema-gebruik']]], + 'openregister not installed' => ['uuid-1', [], true, ['noOpenRegister' => true]], + 'ambtenaar module with filter' => ['uuid-mod', ['organisation' => 'org-x', '_limit' => 5], true, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'ambtenaar module without filter' => ['uuid-mod', ['_page' => 2], true, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'ambtenaar organisation with filter' => ['uuid-org', ['organisation' => 'org-x'], true, ['found' => ['owner' => 'org-other', 'schema' => 'schema-organisatie']]], + 'ambtenaar organisation default id' => ['uuid-org', [], true, ['found' => ['owner' => 'org-other', 'schema' => '15'], 'config' => ['register' => 'reg-1', 'gebruik_schema' => 'schema-gebruik', 'koppeling_schema' => 'schema-koppeling']]], + 'ambtenaar find throws' => ['uuid-mod', [], true, ['findThrows' => true]], + 'ambtenaar find returns null' => ['uuid-mod', [], true, ['found' => null]], + 'ambtenaar without a session' => ['uuid-mod', [], true, ['noUser' => true, 'found' => $module]], + 'owner module, forged filter' => ['uuid-mod', ['organisation' => 'org-forged'], false, ['found' => $module]], + 'owner organisation' => ['uuid-org', [], false, ['found' => $org]], + 'not the owner' => ['uuid-mod', [], false, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'no owner on the object' => ['uuid-mod', [], false, ['found' => ['owner' => null, 'schema' => 'schema-module']]], + 'no session' => ['uuid-mod', [], false, ['noUser' => true, 'found' => $module]], + 'no active organisation' => ['uuid-mod', [], false, ['noActiveOrg' => true, 'found' => $module]], + 'organisation lookup throws' => ['uuid-mod', [], false, ['orgThrows' => true, 'found' => $module]], + 'ownership lookup throws' => ['uuid-mod', [], false, ['findThrows' => true]], + 'ownership object missing' => ['uuid-mod', [], false, ['found' => null]], + 'search throws' => ['uuid-mod', [], true, ['found' => $module, 'searchThrows' => true]], + ]; + + }//end cases() + + /** + * Each case matches its recording. + * + * @param string $uuid The uuid looked up. + * @param array $options The query options. + * @param bool $isAmbtenaar Whether the caller is an ambtenaar. + * @param array $plan The behaviour of the doubles. + * + * @return void + * + * @dataProvider cases + */ + public function testLookupMatchesTheRecording(string $uuid, array $options, bool $isAmbtenaar, array $plan): void { + $this->plan = $plan; + $this->trace = []; + try { + $result = $this->service()->getKoppelingenGebruikByUuid(uuid: $uuid, options: $options, isAmbtenaar: $isAmbtenaar); + } catch (\Throwable $e) { + $result = ['escaped' => get_class($e), 'message' => $e->getMessage()]; + } + + $key = json_encode([$uuid, $options, $isAmbtenaar, $plan]); + $this->check(key: $key, value: ['result' => $result, 'calls' => $this->trace]); + + }//end testLookupMatchesTheRecording() + + /** + * The service with recording doubles. + * + * @return AangebodenGebruikService + */ + private function service(): AangebodenGebruikService { + $appManager = $this->createMock(IAppManager::class); + $appManager->method('getInstalledApps')->willReturn(isset($this->plan['noOpenRegister']) === true ? ['files'] : ['openregister']); + + $objectService = $this->objectService(); + $organisationService = $this->organisationService(); + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($objectService, $organisationService) { + $this->trace[] = ['container.get', $id]; + if ($id === 'OCA\OpenRegister\Service\OrganisationService') { + if (isset($this->plan['orgThrows']) === true) { + throw new \RuntimeException('organisation service down'); + } + + return $organisationService; + } + + return $objectService; + } + ); + + $settings = $this->createMock(SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturnCallback( + function (): array { + $this->trace[] = ['settings.getVoorzieningenConfig']; + return $this->plan['config'] ?? [ + 'register' => 'reg-1', + 'gebruik_schema' => 'schema-gebruik', + 'koppeling_schema' => 'schema-koppeling', + 'organisatie_schema' => 'schema-organisatie', + ]; + } + ); + + $userSession = $this->createMock(IUserSession::class); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('acting-user'); + $userSession->method('getUser')->willReturn(isset($this->plan['noUser']) === true ? null : $user); + + return new AangebodenGebruikService( + config: $this->createMock(IAppConfig::class), + appManager: $appManager, + container: $container, + logger: $this->logger(), + settingsService: $settings, + userSession: $userSession + ); + + }//end service() + + /** + * An organisation service whose active organisation is the acting one. + * + * @return OrganisationService + */ + private function organisationService(): OrganisationService { + $organisation = new Organisation(); + $organisation->setUuid(self::ACTING_ORG); + $service = $this->createMock(OrganisationService::class); + $service->method('getActiveOrganisation')->willReturnCallback( + function () use ($organisation): ?Organisation { + $this->trace[] = ['organisations.getActiveOrganisation']; + return isset($this->plan['noActiveOrg']) === true ? null : $organisation; + } + ); + + return $service; + + }//end organisationService() + + /** + * An object service that records the lookups and the search. + * + * @return ObjectServiceInterface + */ + private function objectService(): ObjectServiceInterface { + $objectService = $this->createMock(ObjectServiceInterface::class); + $objectService->method('find')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.find', $args[0], $args[5] ?? null, $args[6] ?? null]; + if (isset($this->plan['findThrows']) === true) { + throw new \RuntimeException('find failed'); + } + + $found = $this->plan['found'] ?? null; + if ($found === null) { + return null; + } + + $entity = $this->createMock(ObjectEntity::class); + $entity->method('getObject')->willReturn(['@self' => ['organisation' => $found['owner'], 'schema' => $found['schema']]]); + return $entity; + } + ); + $objectService->method('buildSearchQuery')->willReturnCallback( + function (array $requestParams): array { + $this->trace[] = ['objects.buildSearchQuery', $requestParams]; + return ['@self' => ['seed' => true], '_limit' => $requestParams['_limit'] ?? 20]; + } + ); + $objectService->method('searchObjectsPaginated')->willReturnCallback( + function (...$args): array { + $this->trace[] = [ + 'objects.searchObjectsPaginated', + [ + 'query' => $args[0], + '_rbac' => $args[1], + '_multitenancy' => $args[2], + 'deleted' => $args[3], + 'uses' => $args[5] ?? null, + ], + ]; + if (isset($this->plan['searchThrows']) === true) { + throw new \RuntimeException('search failed'); + } + + return ['results' => [['id' => 'g-1']], 'total' => 1, 'page' => 1, 'pages' => 1]; + } + ); + + return $objectService; + + }//end objectService() + + /** + * A logger that records every line, without stack traces. + * + * @return AbstractLogger + */ + private function logger(): AbstractLogger { + $trace = &$this->trace; + return new class($trace) extends AbstractLogger { + /** + * Build the logger. + * + * @param array $trace The shared trace. + */ + public function __construct(private array &$trace) { + } + + /** + * Record one line. + * + * @param mixed $level The level. + * @param string|\Stringable $message The message. + * @param array $context The context. + * + * @return void + */ + public function log($level, string|\Stringable $message, array $context = []): void { + unset($context['trace']); + $this->trace[] = ['log.' . $level, (string) $message, $context]; + } + }; + + }//end logger() + + /** + * Compare a case with the golden file, or record it. + * + * @param string $key The case. + * @param array $value What the case observed. + * + * @return void + */ + private function check(string $key, array $value): void { + $json = json_decode(json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR), true); + $file = self::golden(); + $saved = []; + if (file_exists($file) === true) { + $saved = json_decode((string) file_get_contents($file), true); + } + + if (getenv('RECORD_GOLDEN') !== false) { + $saved[$key] = $json; + file_put_contents($file, json_encode($saved, (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . "\n"); + $this->addToAssertionCount(count: 1); + return; + } + + $this->assertArrayHasKey(key: $key, array: $saved, message: 'No golden for ' . $key); + $this->assertSame(expected: $saved[$key], actual: $json); + + }//end check() + +}//end class diff --git a/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json b/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json new file mode 100644 index 00000000..fcde4005 --- /dev/null +++ b/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json @@ -0,0 +1,1369 @@ +{ + "[\"\",[],true,[]]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0, + "error": "UUID is required" + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "", + "options": [], + "isAmbtenaar": true + } + ] + ] + }, + "[\"uuid-1\",[],true,{\"config\":{\"register\":\"reg-1\",\"gebruik_schema\":\"schema-gebruik\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0, + "error": "Failed to retrieve objects: Voorzieningen configuration not found. Please configure the schemas in the admin panel." + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-1", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.error", + "Failed to get koppelingen-gebruik by UUID", + { + "uuid": "uuid-1", + "error": "Voorzieningen configuration not found. Please configure the schemas in the admin panel." + } + ] + ] + }, + "[\"uuid-1\",[],true,{\"noOpenRegister\":true}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0, + "error": "Failed to retrieve objects: OpenRegister app is not installed" + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-1", + "options": [], + "isAmbtenaar": true + } + ], + [ + "log.error", + "Failed to get koppelingen-gebruik by UUID", + { + "uuid": "uuid-1", + "error": "OpenRegister app is not installed" + } + ] + ] + }, + "[\"uuid-mod\",{\"organisation\":\"org-x\",\"_limit\":5},true,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "organisation": "org-x", + "_limit": 5 + }, + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + { + "organisation": "org-x", + "_limit": 5 + } + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "org-x" + }, + "_limit": 5, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "org-x" + }, + "_limit": 5, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",{\"_page\":2},true,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "_page": 2 + }, + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + { + "_page": 2 + } + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-org\",{\"organisation\":\"org-x\"},true,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-organisatie\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-org", + "options": { + "organisation": "org-x" + }, + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + { + "organisation": "org-x" + } + ], + [ + "objects.find", + "uuid-org", + false, + false + ], + [ + "log.warning", + "Organisation filter parameter is ignored when UUID is already an organisation", + { + "uuid": "uuid-org", + "filter": "org-x" + } + ], + [ + "log.debug", + "Executing koppelingen-gebruik by organisation UUID", + { + "uuid": "uuid-org", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": null + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-org", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-org\",[],true,{\"found\":{\"owner\":\"org-other\",\"schema\":\"15\"},\"config\":{\"register\":\"reg-1\",\"gebruik_schema\":\"schema-gebruik\",\"koppeling_schema\":\"schema-koppeling\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-org", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-org", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by organisation UUID", + { + "uuid": "uuid-org", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": null + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-org", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",[],true,{\"findThrows\":true}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Could not fetch UUID object, assuming it is not an organisation", + { + "uuid": "uuid-mod" + } + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",[],true,{\"found\":null}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",[],true,{\"noUser\":true,\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",{\"organisation\":\"org-forged\"},false,{\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "organisation": "org-forged" + }, + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "objects.buildSearchQuery", + { + "organisation": "org-forged" + } + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-org\",[],false,{\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-organisatie\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-org", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-org", + false, + false + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-org", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by organisation UUID", + { + "uuid": "uuid-org", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ], + "organisation": "uuid-org" + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": null + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-org", + "total": 1, + "results_count": 1 + } + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":{\"owner\":null,\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],false,{\"noUser\":true,\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ] + ] + }, + "[\"uuid-mod\",[],false,{\"noActiveOrg\":true,\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ] + ] + }, + "[\"uuid-mod\",[],false,{\"orgThrows\":true,\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.error", + "Failed to get current organisation from OpenRegister", + { + "error": "organisation service down" + } + ] + ] + }, + "[\"uuid-mod\",[],false,{\"findThrows\":true}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.warning", + "Failed to check ownership for UUID", + { + "uuid": "uuid-mod", + "error": "find failed" + } + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":null}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],true,{\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"},\"searchThrows\":true}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0, + "error": "Failed to retrieve objects: search failed" + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": true + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.buildSearchQuery", + [] + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.error", + "Failed to get koppelingen-gebruik by UUID", + { + "uuid": "uuid-mod", + "error": "search failed" + } + ] + ] + } +} From 07be41c51e41302dc9615b2ec1634b855d10384e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 14:55:42 +0100 Subject: [PATCH 02/10] refactor(aangeboden-gebruik): split the koppelingen lookup by uuid into access, query and search steps getKoppelingenGebruikByUuid keeps its signature and its error envelope; the work moves into koppelingenGebruik, mayReadKoppelingen (ambtenaar, or the active organisation owns the object), searchKoppelingen, isOrganisationUuid and emptyKoppelingenPage, each with at most two parameters. Order of the lookups, the log lines and the results are unchanged (golden of the previous commit). Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/AangebodenGebruikService.php | 370 ++++++++++++----------- 1 file changed, 200 insertions(+), 170 deletions(-) diff --git a/lib/Service/AangebodenGebruikService.php b/lib/Service/AangebodenGebruikService.php index 6b5cc854..72bd029f 100644 --- a/lib/Service/AangebodenGebruikService.php +++ b/lib/Service/AangebodenGebruikService.php @@ -56,6 +56,8 @@ * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.CamelCaseVariableName) * @SuppressWarnings(PHPMD.CamelCaseParameterName) + * + * @spec openspec/specs/aangeboden-gebruik-api/spec.md#requirement-the-system-shall-list-offered-usage-records-scoped-to-the-callers-role-req-001 */ class AangebodenGebruikService { /** @@ -321,212 +323,240 @@ public function getGebruiksWhereAfnemer(array $options = []): array { * * @return array searchObjectsPaginated result with koppelingen and gebruiks for the UUID. * - * @throws Exception When OpenRegister service is not available. - * * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $isAmbtenaar is a simple privilege toggle * @spec openspec/specs/aangeboden-gebruik-api/spec.md */ public function getKoppelingenGebruikByUuid(string $uuid, array $options = [], bool $isAmbtenaar = false): array { - $this->logger->info( - 'Getting koppelingen and gebruiks for UUID with extended access', - [ - 'uuid' => $uuid, - 'options' => $options, - 'isAmbtenaar' => $isAmbtenaar, - ] - ); + $request = [ + 'uuid' => $uuid, + 'options' => $options, + 'isAmbtenaar' => $isAmbtenaar, + ]; + $this->logger->info('Getting koppelingen and gebruiks for UUID with extended access', $request); try { - // Validate input. - if (empty($uuid) === true) { - return [ - 'results' => [], - 'total' => 0, - 'page' => 1, - 'pages' => 0, - 'limit' => 20, - 'offset' => 0, - 'error' => 'UUID is required', - ]; - } + return $this->koppelingenGebruik(request: $request); + } catch (Exception $e) { + $this->logger->error( + 'Failed to get koppelingen-gebruik by UUID', + [ + 'uuid' => $uuid, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ] + ); - // Get ObjectService from OpenRegister. - $objectService = $this->getObjectService(); + return $this->emptyKoppelingenPage(error: 'Failed to retrieve objects: ' . $e->getMessage()); + } + }//end getKoppelingenGebruikByUuid() - // Get voorzieningen configuration. - $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); - $registerId = $voorzieningenConfig['register'] ?? null; - $gebruikSchema = $voorzieningenConfig['gebruik_schema'] ?? null; - $koppeligenSchema = $voorzieningenConfig['koppeling_schema'] ?? null; + /** + * The koppelingen and gebruiks for a uuid, when the caller may read them. + * + * @param array{uuid: string, options: array, isAmbtenaar: bool} $request The uuid, query options and ambtenaar flag + * + * @return array searchObjectsPaginated result, or an empty page + * + * @throws Exception When OpenRegister or the voorzieningen configuration is not available. + */ + private function koppelingenGebruik(array $request): array { + // Validate input. + if (empty($request['uuid']) === true) { + return $this->emptyKoppelingenPage(error: 'UUID is required'); + } - if ($registerId === null || $gebruikSchema === null || $koppeligenSchema === null) { - throw new Exception( - 'Voorzieningen configuration not found. Please configure the schemas in the admin panel.' - ); - } + // Get ObjectService from OpenRegister. + $request['objectService'] = $this->getObjectService(); - // Check access permissions. - $currentOrg = $this->getCurrentOrganisation(); - $hasAccess = false; + // Get voorzieningen configuration. + $request['config'] = $this->settingsService->getVoorzieningenConfig(); + $registerId = $request['config']['register'] ?? null; + $gebruikSchema = $request['config']['gebruik_schema'] ?? null; + $koppeligenSchema = $request['config']['koppeling_schema'] ?? null; - if ($isAmbtenaar === true) { - // Ambtenaar always has access. - $hasAccess = true; - } elseif ($currentOrg !== null) { - // Check if the application/module is owned by user's organization. - try { - $appObject = $objectService->find(id: $uuid, _rbac: false, _multitenancy: false); - if ($appObject !== null) { - $appData = $appObject->getObject(); - $ownerOrg = $appData['@self']['organisation'] ?? null; - $hasAccess = ($ownerOrg === $currentOrg); - } - } catch (Exception $e) { - $this->logger->warning( - 'Failed to check ownership for UUID', - [ - 'uuid' => $uuid, - 'error' => $e->getMessage(), - ] - ); - } - }//end if + if ($registerId === null || $gebruikSchema === null || $koppeligenSchema === null) { + throw new Exception( + 'Voorzieningen configuration not found. Please configure the schemas in the admin panel.' + ); + } - if ($hasAccess === false) { - return [ - 'results' => [], - 'total' => 0, - 'page' => 1, - 'pages' => 0, - 'limit' => 20, - 'offset' => 0, - ]; - } + // Check access permissions. + if ($this->mayReadKoppelingen(request: $request) === false) { + return $this->emptyKoppelingenPage(error: null); + } - // Get organization filter if provided (for ambtenaar). - $organisationFilter = null; - if ($isAmbtenaar === true && isset($options['organisation']) === true) { - $organisationFilter = $options['organisation']; - } + // Build search query using ObjectService's buildSearchQuery. + $searchQuery = $request['objectService']->buildSearchQuery($request['options']); - // Build search query using ObjectService's buildSearchQuery. - $searchQuery = $objectService->buildSearchQuery($options); + // Add register and schema filters. + $searchQuery['@self']['register'] = $registerId; + $searchQuery['@self']['schema'] = [$gebruikSchema, $koppeligenSchema]; - // Add register and schema filters. - $searchQuery['@self']['register'] = $registerId; - $searchQuery['@self']['schema'] = [$gebruikSchema, $koppeligenSchema]; + // Force database source. + $searchQuery['_source'] = 'database'; - // Force database source. - $searchQuery['_source'] = 'database'; + $searchResult = $this->searchKoppelingen(request: $request, searchQuery: $searchQuery); - // Check if UUID is an organisation UUID by trying to fetch it and checking its schema. - $isOrganisationUuid = false; - try { - $uuidObject = $objectService->find(id: $uuid, _rbac: false, _multitenancy: false); - if ($uuidObject !== null) { - $uuidData = $uuidObject->getObject(); - $uuidSchema = $uuidData['@self']['schema'] ?? null; - $organisationSchemaId = $voorzieningenConfig['organisatie_schema'] ?? '15'; - $isOrganisationUuid = ($uuidSchema === $organisationSchemaId); - } - } catch (Exception $e) { - $this->logger->debug( - 'Could not fetch UUID object, assuming it is not an organisation', - [ - 'uuid' => $uuid, - ] - ); - } + $this->logger->debug( + 'Koppelingen-gebruik by UUID search completed', + [ + 'uuid' => $request['uuid'], + 'total' => $searchResult['total'] ?? 0, + 'results_count' => count($searchResult['results'] ?? []), + ] + ); - // Handle organisation UUID filtering differently from suite/module UUIDs. - if ($isOrganisationUuid === true) { - // For organisation UUIDs, filter by @self.organisation. - $searchQuery['@self']['organisation'] = $uuid; + return $searchResult; + }//end koppelingenGebruik() - // Apply additional organisation filter if provided by ambtenaar. - if ($organisationFilter !== null) { - $this->logger->warning( - 'Organisation filter parameter is ignored when UUID is already an organisation', - [ - 'uuid' => $uuid, - 'filter' => $organisationFilter, - ] - ); - } + /** + * Whether the caller may read the koppelingen of a uuid: an ambtenaar always, anyone + * else only when their active organisation owns the object behind the uuid. + * + * @param array $request The uuid, ambtenaar flag and object service + * + * @return bool True when access is granted + */ + private function mayReadKoppelingen(array $request): bool { + $currentOrg = $this->getCurrentOrganisation(); + if ($request['isAmbtenaar'] === true) { + return true; + } - $this->logger->debug( - 'Executing koppelingen-gebruik by organisation UUID', - [ - 'uuid' => $uuid, - 'query' => $searchQuery, - ] - ); + if ($currentOrg === null) { + return false; + } - // Execute paginated search without 'uses' parameter. - $searchResult = $objectService->searchObjectsPaginated( - query: $searchQuery, - _rbac: false, - _multitenancy: false, - deleted: false - ); - }//end if + // Check if the application/module is owned by user's organization. + try { + $appObject = $request['objectService']->find(id: $request['uuid'], _rbac: false, _multitenancy: false); + if ($appObject !== null) { + $appData = $appObject->getObject(); + $ownerOrg = $appData['@self']['organisation'] ?? null; + return $ownerOrg === $currentOrg; + } + } catch (Exception $e) { + $this->logger->warning( + 'Failed to check ownership for UUID', + [ + 'uuid' => $request['uuid'], + 'error' => $e->getMessage(), + ] + ); + } - if ($isOrganisationUuid === false) { - // For suite/module UUIDs, use 'uses' parameter to filter by relations. - // Add organization filter if provided. - if ($organisationFilter !== null) { - $searchQuery['@self']['organisation'] = $organisationFilter; - } + return false; + }//end mayReadKoppelingen() + + /** + * Run the koppelingen search: by @self.organisation for an organisation uuid, by relation otherwise. + * + * An ambtenaar's organisation filter narrows a relation search; it is ignored for an organisation uuid. + * + * @param array $request The uuid, options, ambtenaar flag, object service and configuration + * @param array $searchQuery The query with register, schema and source set + * + * @return array searchObjectsPaginated result + */ + private function searchKoppelingen(array $request, array $searchQuery): array { + $uuid = $request['uuid']; + + // Get organization filter if provided (for ambtenaar). + $organisationFilter = null; + if ($request['isAmbtenaar'] === true && isset($request['options']['organisation']) === true) { + $organisationFilter = $request['options']['organisation']; + } + + if ($this->isOrganisationUuid(request: $request) === true) { + // For organisation UUIDs, filter by @self.organisation. + $searchQuery['@self']['organisation'] = $uuid; - $this->logger->debug( - 'Executing koppelingen-gebruik by suite/module UUID', + if ($organisationFilter !== null) { + $this->logger->warning( + 'Organisation filter parameter is ignored when UUID is already an organisation', [ 'uuid' => $uuid, - 'query' => $searchQuery, + 'filter' => $organisationFilter, ] ); + } - // Execute paginated search using 'uses' parameter to filter by UUID in relations. - $searchResult = $objectService->searchObjectsPaginated( - query: $searchQuery, - _rbac: false, - _multitenancy: false, - deleted: false, - uses: $uuid - ); - }//end if + $this->logger->debug('Executing koppelingen-gebruik by organisation UUID', ['uuid' => $uuid, 'query' => $searchQuery]); - $this->logger->debug( - 'Koppelingen-gebruik by UUID search completed', - [ - 'uuid' => $uuid, - 'total' => $searchResult['total'] ?? 0, - 'results_count' => count($searchResult['results'] ?? []), - ] + return $request['objectService']->searchObjectsPaginated( + query: $searchQuery, + _rbac: false, + _multitenancy: false, + deleted: false ); + }//end if - return $searchResult; + // For suite/module UUIDs, use 'uses' parameter to filter by relations. + if ($organisationFilter !== null) { + $searchQuery['@self']['organisation'] = $organisationFilter; + } + + $this->logger->debug('Executing koppelingen-gebruik by suite/module UUID', ['uuid' => $uuid, 'query' => $searchQuery]); + + return $request['objectService']->searchObjectsPaginated( + query: $searchQuery, + _rbac: false, + _multitenancy: false, + deleted: false, + uses: $uuid + ); + }//end searchKoppelingen() + + /** + * Whether a uuid names an organisation: its object's schema is the configured organisation schema. + * + * @param array $request The uuid, object service and configuration + * + * @return bool True for an organisation uuid; false when the object cannot be fetched + */ + private function isOrganisationUuid(array $request): bool { + try { + $uuidObject = $request['objectService']->find(id: $request['uuid'], _rbac: false, _multitenancy: false); + if ($uuidObject !== null) { + $uuidData = $uuidObject->getObject(); + $uuidSchema = $uuidData['@self']['schema'] ?? null; + $organisationSchemaId = $request['config']['organisatie_schema'] ?? '15'; + return $uuidSchema === $organisationSchemaId; + } } catch (Exception $e) { - $this->logger->error( - 'Failed to get koppelingen-gebruik by UUID', + $this->logger->debug( + 'Could not fetch UUID object, assuming it is not an organisation', [ - 'uuid' => $uuid, - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), + 'uuid' => $request['uuid'], ] ); + } - return [ - 'results' => [], - 'total' => 0, - 'page' => 1, - 'pages' => 0, - 'limit' => 20, - 'offset' => 0, - 'error' => 'Failed to retrieve objects: ' . $e->getMessage(), - ]; - }//end try - }//end getKoppelingenGebruikByUuid() + return false; + }//end isOrganisationUuid() + + /** + * An empty result page, with an error when one is given. + * + * @param string|null $error The error to report, or null + * + * @return array The empty page + */ + private function emptyKoppelingenPage(?string $error): array { + $page = [ + 'results' => [], + 'total' => 0, + 'page' => 1, + 'pages' => 0, + 'limit' => 20, + 'offset' => 0, + ]; + if ($error !== null) { + $page['error'] = $error; + } + + return $page; + }//end emptyKoppelingenPage() /** * Get all gebruiks objects (ignoring RBAC and multitenancy) - restricted to ambtenaar group From 69018aee5c5a0e92601ab5fce36395f08b6af845 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 14:58:37 +0100 Subject: [PATCH 03/10] test(organization-sync): golden for the related contact person sync Assisted-by: ClaudeCode:claude-opus-5-5 --- ...anizationSyncRelatedContactsGoldenTest.php | 642 ++++++++ .../organizationSyncRelatedContacts.json | 1374 +++++++++++++++++ 2 files changed, 2016 insertions(+) create mode 100644 tests/Unit/Service/OrganizationSyncRelatedContactsGoldenTest.php create mode 100644 tests/Unit/Service/golden/organizationSyncRelatedContacts.json diff --git a/tests/Unit/Service/OrganizationSyncRelatedContactsGoldenTest.php b/tests/Unit/Service/OrganizationSyncRelatedContactsGoldenTest.php new file mode 100644 index 00000000..9928614e --- /dev/null +++ b/tests/Unit/Service/OrganizationSyncRelatedContactsGoldenTest.php @@ -0,0 +1,642 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/organization-sync/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\Stackiq\Service\OrganisatieService; +use OCA\Stackiq\Service\OrganizationSyncService; +use OCA\Stackiq\Service\SettingsService; +use OCA\Stackiq\Service\Stackiq\ContactPersonHandler; +use OCA\Stackiq\Service\SymfonyEmailService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IUser; +use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; + +/** + * Golden cases for the related contact person sync. + * + * @covers \OCA\Stackiq\Service\OrganizationSyncService + * + * @spec openspec/specs/organization-sync/spec.md + */ +class OrganizationSyncRelatedContactsGoldenTest extends TestCase { + + /** + * Everything the doubles saw, in order. + * + * @var array + */ + private array $trace = []; + + /** + * Per-case behaviour of the doubles. + * + * @var array + */ + private array $plan = []; + + /** + * The golden file. + * + * @return string + */ + private static function golden(): string { + return __DIR__ . '/golden/organizationSyncRelatedContacts.json'; + + }//end golden() + + /** + * A contact or organisation object double that records its writes. + * + * @param array $object The object data. + * @param string $uuid The uuid. + * + * @return ObjectEntityInterface + */ + private function entity(array $object, string $uuid): ObjectEntityInterface { + $trace = &$this->trace; + return new class($object, $uuid, $trace) implements ObjectEntityInterface { + /** + * The owner set on the entity. + * + * @var string|null + */ + private ?string $owner = null; + + /** + * The organisation set on the entity. + * + * @var string|null + */ + private ?string $organisation = 'org-of-entity'; + + /** + * Build the double. + * + * @param array $object The object data. + * @param string $uuid The uuid. + * @param array $trace The shared trace. + */ + public function __construct(private array $object, private string $uuid, private array &$trace) { + } + + /** + * @return mixed + */ + public function getId() { + return 7; + } + + /** + * @return string|null + */ + public function getUuid(): ?string { + return $this->uuid; + } + + /** + * @return array + */ + public function getObject(): array { + return $this->object; + } + + /** + * @return string|null + */ + public function getRegister(): ?string { + return 'reg-entity'; + } + + /** + * @return string|null + */ + public function getSchema(): ?string { + return 'schema-entity'; + } + + /** + * @return string|null + */ + public function getOrganisation(): ?string { + return $this->organisation; + } + + /** + * @return string|null + */ + public function getOwner(): ?string { + return $this->owner; + } + + /** + * @param mixed $object The data. + * + * @return void + */ + public function setObject($object=null) { + $this->trace[] = ['entity.setObject', $this->uuid, $object]; + $this->object = $object; + } + + /** + * @param string|null $owner The owner. + * + * @return void + */ + public function setOwner(?string $owner): void { + $this->trace[] = ['entity.setOwner', $this->uuid, $owner]; + $this->owner = $owner; + } + + /** + * @param string|null $organisation The organisation. + * + * @return void + */ + public function setOrganisation(?string $organisation): void { + $this->trace[] = ['entity.setOrganisation', $this->uuid, $organisation]; + $this->organisation = $organisation; + } + + /** + * @return mixed + */ + public function jsonSerialize(): mixed { + return $this->object; + } + + /** + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + $this->trace[] = ['entity.' . $name, $this->uuid, $args]; + return null; + } + }; + + }//end entity() + + /** + * Arguments by parameter name, leaving out the ones left at their default. + * + * @param string $method The ObjectServiceInterface method. + * @param array $args The positional arguments the double received. + * + * @return array + */ + private static function named(string $method, array $args): array { + $named = []; + foreach ((new \ReflectionMethod(ObjectServiceInterface::class, $method))->getParameters() as $i => $param) { + if (array_key_exists($i, $args) === false) { + continue; + } + + if ($param->isDefaultValueAvailable() === true && $param->getDefaultValue() === $args[$i]) { + continue; + } + + $value = $args[$i]; + if ($value instanceof ObjectEntityInterface) { + $value = 'entity:' . $value->getUuid(); + } + + $named[$param->getName()] = $value; + } + + return $named; + + }//end named() + + /** + * Build the service with recording doubles driven by $this->plan. + * + * @return OrganizationSyncService + */ + private function service(): OrganizationSyncService { + $trace = &$this->trace; + $logger = new class($trace) extends AbstractLogger { + /** + * @param array $trace The shared trace. + */ + public function __construct(private array &$trace) { + } + + /** + * @param mixed $level The level. + * @param string|\Stringable $message The message. + * @param array $context The context. + * + * @return void + */ + public function log($level, string|\Stringable $message, array $context=[]): void { + foreach (['trace', 'file', 'line'] as $volatile) { + if (array_key_exists($volatile, $context) === true) { + $context[$volatile] = '(' . $volatile . ')'; + } + } + + $this->trace[] = ['log.' . $level, (string) $message, $context]; + } + }; + + $objects = $this->createMock(originalClassName: ObjectServiceInterface::class); + $objects->method('find')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.find', self::named(method: 'find', args: $args)]; + $answer = array_shift($this->plan['find']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + $objects->method('searchObjects')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.searchObjects', self::named(method: 'searchObjects', args: $args)]; + $answer = array_shift($this->plan['search']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + $objects->method('saveObject')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.saveObject', self::named(method: 'saveObject', args: $args)]; + $answer = array_shift($this->plan['save']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return ($answer ?? $this->entity(object: ['saved' => true], uuid: 'saved-uuid')); + } + ); + + $mapper = $this->createMock(originalClassName: OrganisationMapper::class); + $mapper->method('findByUuid')->willReturnCallback( + function (string $uuid) { + $this->trace[] = ['mapper.findByUuid', $uuid]; + $answer = array_shift($this->plan['org']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + + $handler = $this->createMock(originalClassName: ContactPersonHandler::class); + $handler->method('isFirstContactForOrganization')->willReturnCallback( + function ($contact, $data) { + $this->trace[] = ['handler.isFirst', $contact->getUuid(), $data]; + return $this->plan['first'] ?? false; + } + ); + $handler->method('createUserAccount')->willReturnCallback( + function ($contact, $isFirst) { + $this->trace[] = ['handler.createUserAccount', $contact->getUuid(), $isFirst]; + return ($this->plan['user'] ?? null); + } + ); + $handler->method('addUserToOrganizationEntity')->willReturnCallback( + function (...$args) { + $args[0] = $args[0]->getUuid(); + $this->trace[] = ['handler.addUserToOrganizationEntity', $args]; + return true; + } + ); + + $settings = $this->createMock(originalClassName: SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturnCallback( + function () { + $this->trace[] = ['settings.getVoorzieningenConfig']; + $answer = ($this->plan['config'] ?? ['register' => 'reg-v', 'organisatie_schema' => 'org-schema']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + + // Built without the constructor: it type-hints IDBConnection, whose + // mock needs Doctrine, and the methods under test never touch it. + $reflection = new \ReflectionClass(OrganizationSyncService::class); + $service = $this->getMockBuilder(OrganizationSyncService::class) + ->disableOriginalConstructor() + ->onlyMethods(['processSpecificContactPerson']) + ->getMock(); + $service->method('processSpecificContactPerson')->willReturnCallback( + function ($contactObject) { + $this->trace[] = ['service.processSpecificContactPerson', $contactObject->getUuid(), $contactObject->getObject()]; + $answer = array_shift($this->plan['process']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return ($answer ?? ['contactPersonsProcessed' => 1, 'usersCreated' => 0, 'usersUpdated' => 1, 'errors' => []]); + } + ); + $properties = [ + 'logger' => $logger, + 'settingsService' => $settings, + 'contactpersonHandler' => $handler, + 'objectService' => $objects, + 'organisationMapper' => $mapper, + 'organisationService' => $this->organisationService(), + 'emailService' => $this->createMock(originalClassName: SymfonyEmailService::class), + ]; + foreach ($properties as $name => $value) { + $reflection->getProperty($name)->setValue($service, $value); + } + + return $service; + + }//end service() + + /** + * An organisation service double that records the organisations it creates. + * + * @return OrganisatieService + */ + private function organisationService(): OrganisatieService { + $service = $this->createMock(originalClassName: OrganisatieService::class); + $service->method('createOrganisationInOpenRegister')->willReturnCallback( + function (array $data) { + $this->trace[] = ['organisations.create', $data]; + return self::organisation(active: true); + } + ); + return $service; + + }//end organisationService() + + /** + * An organisation entity double. + * + * @param mixed $active What getActive() answers, or a Throwable it throws. + * + * @return Organisation + */ + private static function organisation(mixed $active): Organisation { + return new class($active) extends Organisation { + /** + * @param mixed $active The answer. + */ + public function __construct(private mixed $active) { + } + + /** + * @return mixed + */ + public function getActive(): mixed { + if ($this->active instanceof \Throwable) { + throw $this->active; + } + + return $this->active; + } + + /** + * @return string + */ + public function getName(): string { + return 'Org'; + } + + /** + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + return null; + } + }; + + }//end organisation() + + /** + * A user double. + * + * @param string $uid The uid. + * + * @return IUser + */ + private function user(string $uid): IUser { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn($uid); + return $user; + + }//end user() + + /** + * The cases: organisation uuid and the doubles' answers. + * + * @return array + */ + public static function relatedCases(): array { + $config = ['register' => '5', 'contactpersoon_schema' => '9', 'organisatie_schema' => '7']; + return [ + 'no contact schema' => ['org-1', fn ($t) => ['config' => ['register' => '5']]], + 'no register' => ['org-1', fn ($t) => ['config' => ['contactpersoon_schema' => '9']]], + 'found by organization field' => [ + 'org-1', + fn ($t) => [ + 'config' => $config, + 'search' => [[ + $t->entity(['email' => 'a@x.nl', 'organization' => 'org-1'], 'c-a'), + $t->entity(['e-mailadres' => 'b@x.nl'], 'c-b'), + $t->entity(['voornaam' => 'C', 'organization' => 'org-1'], 'c-c'), + ], + ], + 'find' => [$t->entity(['email' => 'c@x.nl', 'organization' => 'org-1'], 'c-c')], + 'process' => [null, ['contactPersonsProcessed' => 1, 'usersCreated' => 1, 'usersUpdated' => 0, 'errors' => ['user b failed']], []], + ], + ], + 'found by organisation field' => [ + 'org-2', + fn ($t) => [ + 'config' => $config, + 'search' => [[], [$t->entity(['email' => 'd@x.nl', 'organization' => 'org-2'], 'c-d')]], + ], + ], + 'fallback to the contactpersonen array' => [ + 'org-3', + fn ($t) => [ + 'config' => $config, + 'search' => [[], []], + 'find' => [ + $t->entity(['contactpersonen' => ['c-e', '', 42, 'c-gone', 'c-null', 'c-f']], 'org-3'), + $t->entity(['email' => 'e@x.nl'], 'c-e'), + new \RuntimeException('contact gone'), + null, + $t->entity(['email' => 'f@x.nl', 'organization' => 'org-other'], 'c-f'), + ], + ], + ], + 'fallback org has no contacts' => [ + 'org-4', + fn ($t) => [ + 'config' => $config, + 'search' => [[], []], + 'find' => [$t->entity(['naam' => 'Org 4'], 'org-4')], + ], + ], + 'fallback org missing' => [ + 'org-5', + fn ($t) => [ + 'config' => $config, + 'search' => [[], []], + 'find' => [null], + ], + ], + 'fallback org lookup throws' => [ + 'org-6', + fn ($t) => [ + 'config' => $config, + 'search' => [[], []], + 'find' => [new \RuntimeException('org gone')], + ], + ], + 'refetch throws, process throws' => [ + 'org-7', + fn ($t) => [ + 'config' => $config, + 'search' => [[$t->entity(['organization' => 'org-7'], 'c-g'), $t->entity(['email' => 'h@x.nl'], 'c-h')]], + 'find' => [new \RuntimeException('refetch failed')], + 'process' => [new \RuntimeException('process failed'), null], + ], + ], + 'refetch returns null' => [ + 'org-8', + fn ($t) => [ + 'config' => $config, + 'search' => [[$t->entity(['organization' => 'org-8'], 'c-i')]], + 'find' => [null], + ], + ], + 'save of the organisation field throws' => [ + 'org-9', + fn ($t) => [ + 'config' => $config, + 'search' => [[$t->entity(['email' => 'j@x.nl'], 'c-j')]], + 'save' => [new \RuntimeException('save failed')], + ], + ], + 'search throws' => [ + 'org-10', + fn ($t) => [ + 'config' => $config, + 'search' => [new \RuntimeException('search failed')], + ], + ], + 'settings throw' => ['org-11', fn ($t) => ['config' => new \RuntimeException('settings down')]], + ]; + + }//end relatedCases() + + /** + * Each related contact case matches its recording. + * + * @param string $organizationUuid The organisation uuid. + * @param \Closure $plan Builds the doubles' answers. + * + * @return void + * + * @dataProvider relatedCases + */ + public function testRelatedContacts(string $organizationUuid, \Closure $plan): void { + $this->trace = []; + $this->plan = array_merge(['find' => [], 'save' => [], 'org' => [], 'search' => [], 'process' => []], $plan($this)); + $service = $this->service(); + $stats = ['contactPersonsProcessed' => 0, 'usersCreated' => 0, 'usersUpdated' => 0, 'errors' => ['earlier error']]; + $this->callRelated(service: $service, organizationUuid: $organizationUuid, stats: $stats); + $this->check(key: 'related: ' . $this->dataName(), value: ['stats' => $stats, 'trace' => $this->trace]); + + }//end testRelatedContacts() + + /** + * Call processRelatedContactPersons with the organisation uuid and the stats. + * + * @param OrganizationSyncService $service The service. + * @param string $organizationUuid The organisation uuid. + * @param array $stats The stats, updated in place. + * + * @return void + */ + private function callRelated(OrganizationSyncService $service, string $organizationUuid, array &$stats): void { + $method = new \ReflectionMethod(OrganizationSyncService::class, 'processRelatedContactPersons'); + $args = [$organizationUuid]; + if ($method->getNumberOfParameters() === 3) { + $args[] = $this->entity(object: ['naam' => 'Org'], uuid: $organizationUuid); + } + + $args[] = &$stats; + $method->invokeArgs($service, $args); + + }//end callRelated() + + /** + * Compare a case with the golden file, or record it. + * + * @param string $key The case. + * @param array $value What the case observed. + * + * @return void + */ + private function check(string $key, array $value): void { + $json = json_decode(json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR), true); + $file = self::golden(); + $saved = []; + if (file_exists($file) === true) { + $saved = json_decode((string) file_get_contents($file), true); + } + + if (getenv('RECORD_GOLDEN') !== false) { + $saved[$key] = $json; + if (is_dir(dirname($file)) === false) { + mkdir(dirname($file), 0777, true); + } + + file_put_contents($file, json_encode($saved, (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . "\n"); + $this->addToAssertionCount(count: 1); + return; + } + + $this->assertArrayHasKey(key: $key, array: $saved, message: 'No golden for ' . $key); + $this->assertSame(expected: $saved[$key], actual: $json); + + }//end check() + +}//end class diff --git a/tests/Unit/Service/golden/organizationSyncRelatedContacts.json b/tests/Unit/Service/golden/organizationSyncRelatedContacts.json new file mode 100644 index 00000000..57d80778 --- /dev/null +++ b/tests/Unit/Service/golden/organizationSyncRelatedContacts.json @@ -0,0 +1,1374 @@ +{ + "related: no contact schema": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-1" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.warning", + "[FLOW] Related contact processing skipped - configuration missing", + { + "organizationId": "org-1", + "register": "5", + "contactSchema": "" + } + ] + ] + }, + "related: no register": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-1" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.warning", + "[FLOW] Related contact processing skipped - configuration missing", + { + "organizationId": "org-1", + "register": "", + "contactSchema": "9" + } + ] + ] + }, + "related: found by organization field": { + "stats": { + "contactPersonsProcessed": 2, + "usersCreated": 1, + "usersUpdated": 1, + "errors": [ + "earlier error", + "user b failed" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-1" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-1", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-1", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-1", + "_limit": 500 + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-1", + "contactCount": 3 + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-1", + "contactId": "c-a", + "contactEmail": "a@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-a", + { + "email": "a@x.nl", + "organization": "org-1" + } + ], + [ + "entity.setObject", + "c-b", + { + "e-mailadres": "b@x.nl", + "organization": "org-1" + } + ], + [ + "objects.saveObject", + { + "object": { + "e-mailadres": "b@x.nl", + "organization": "org-1" + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-b", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "[FLOW] Set missing organisatie field on related contact", + { + "contactId": "c-b", + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-1", + "contactId": "c-b", + "contactEmail": "b@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-b", + { + "e-mailadres": "b@x.nl", + "organization": "org-1" + } + ], + [ + "log.info", + "[FLOW] Contact data incomplete, re-fetching full object", + { + "organizationId": "org-1", + "contactId": "c-c" + } + ], + [ + "objects.find", + { + "id": "c-c", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-1", + "contactId": "c-c", + "contactEmail": "c@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-c", + { + "email": "c@x.nl", + "organization": "org-1" + } + ] + ] + }, + "related: found by organisation field": { + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "usersUpdated": 1, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-2" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-2", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-2", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-2", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-2", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-2" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-2" + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-2", + "contactCount": 1 + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-2", + "contactId": "c-d", + "contactEmail": "d@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-d", + { + "email": "d@x.nl", + "organization": "org-2" + } + ] + ] + }, + "related: fallback to the contactpersonen array": { + "stats": { + "contactPersonsProcessed": 2, + "usersCreated": 0, + "usersUpdated": 2, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-3" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-3", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-3", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-3", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-3", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-3" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-3" + } + } + ], + [ + "log.info", + "[FLOW] No contacts found by organisatie field, checking org contactpersonen array", + { + "organizationId": "org-3" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-3", + "register": "5", + "schema": "7", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Raw org contactpersonen array", + { + "organizationId": "org-3", + "contactpersonen": [ + "c-e", + "", + 42, + "c-gone", + "c-null", + "c-f" + ], + "count": 6 + } + ], + [ + "objects.find", + { + "id": "c-e", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "objects.find", + { + "id": "c-gone", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.warning", + "[FLOW] Could not fetch contact from contactpersonen array", + { + "organizationId": "org-3", + "contactUuid": "c-gone", + "exception": "contact gone" + } + ], + [ + "objects.find", + { + "id": "c-null", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "objects.find", + { + "id": "c-f", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-3", + "contactCount": 2 + } + ], + [ + "entity.setObject", + "c-e", + { + "email": "e@x.nl", + "organization": "org-3" + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "e@x.nl", + "organization": "org-3" + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-e", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "[FLOW] Set missing organisatie field on related contact", + { + "contactId": "c-e", + "organizationUuid": "org-3" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-3", + "contactId": "c-e", + "contactEmail": "e@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-e", + { + "email": "e@x.nl", + "organization": "org-3" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-3", + "contactId": "c-f", + "contactEmail": "f@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-f", + { + "email": "f@x.nl", + "organization": "org-other" + } + ] + ] + }, + "related: fallback org has no contacts": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-4" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-4", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-4", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-4", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-4", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-4" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-4" + } + } + ], + [ + "log.info", + "[FLOW] No contacts found by organisatie field, checking org contactpersonen array", + { + "organizationId": "org-4" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-4", + "register": "5", + "schema": "7", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Raw org contactpersonen array", + { + "organizationId": "org-4", + "contactpersonen": [], + "count": 0 + } + ], + [ + "log.info", + "[FLOW] No related contact persons found (including contactpersonen fallback)", + { + "organizationId": "org-4" + } + ] + ] + }, + "related: fallback org missing": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-5" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-5", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-5", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-5", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-5", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-5" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-5" + } + } + ], + [ + "log.info", + "[FLOW] No contacts found by organisatie field, checking org contactpersonen array", + { + "organizationId": "org-5" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-5", + "register": "5", + "schema": "7", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Raw org contactpersonen array", + { + "organizationId": "org-5", + "contactpersonen": [], + "count": 0 + } + ], + [ + "log.info", + "[FLOW] No related contact persons found (including contactpersonen fallback)", + { + "organizationId": "org-5" + } + ] + ] + }, + "related: fallback org lookup throws": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-6" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-6", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-6", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-6", + "_limit": 500 + } + } + ], + [ + "log.info", + "[FLOW] Retrying search with organisation field", + { + "organizationId": "org-6", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-6" + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "_limit": 500, + "organisation": "org-6" + } + } + ], + [ + "log.info", + "[FLOW] No contacts found by organisatie field, checking org contactpersonen array", + { + "organizationId": "org-6" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.find", + { + "id": "org-6", + "register": "5", + "schema": "7", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.warning", + "[FLOW] Could not re-fetch org for contactpersonen fallback", + { + "organizationId": "org-6", + "exception": "org gone" + } + ], + [ + "log.info", + "[FLOW] No related contact persons found (including contactpersonen fallback)", + { + "organizationId": "org-6" + } + ] + ] + }, + "related: refetch throws, process throws": { + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "usersUpdated": 1, + "errors": [ + "earlier error", + "Related contact c-g: process failed" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-7" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-7", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-7", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-7", + "_limit": 500 + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-7", + "contactCount": 2 + } + ], + [ + "log.info", + "[FLOW] Contact data incomplete, re-fetching full object", + { + "organizationId": "org-7", + "contactId": "c-g" + } + ], + [ + "objects.find", + { + "id": "c-g", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.warning", + "[FLOW] Failed to re-fetch contact object", + { + "organizationId": "org-7", + "contactId": "c-g", + "exception": "refetch failed" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-7", + "contactId": "c-g", + "contactEmail": "unknown" + } + ], + [ + "service.processSpecificContactPerson", + "c-g", + { + "organization": "org-7" + } + ], + [ + "log.error", + "[FLOW] Failed to process related contact person", + { + "organizationId": "org-7", + "contactId": "c-g", + "exception": "process failed" + } + ], + [ + "entity.setObject", + "c-h", + { + "email": "h@x.nl", + "organization": "org-7" + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "h@x.nl", + "organization": "org-7" + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-h", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "[FLOW] Set missing organisatie field on related contact", + { + "contactId": "c-h", + "organizationUuid": "org-7" + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-7", + "contactId": "c-h", + "contactEmail": "h@x.nl" + } + ], + [ + "service.processSpecificContactPerson", + "c-h", + { + "email": "h@x.nl", + "organization": "org-7" + } + ] + ] + }, + "related: refetch returns null": { + "stats": { + "contactPersonsProcessed": 1, + "usersCreated": 0, + "usersUpdated": 1, + "errors": [ + "earlier error" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-8" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-8", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-8", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-8", + "_limit": 500 + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-8", + "contactCount": 1 + } + ], + [ + "log.info", + "[FLOW] Contact data incomplete, re-fetching full object", + { + "organizationId": "org-8", + "contactId": "c-i" + } + ], + [ + "objects.find", + { + "id": "c-i", + "register": "5", + "schema": "9", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.info", + "[FLOW] Processing related contact person", + { + "organizationId": "org-8", + "contactId": "c-i", + "contactEmail": "unknown" + } + ], + [ + "service.processSpecificContactPerson", + "c-i", + { + "organization": "org-8" + } + ] + ] + }, + "related: save of the organisation field throws": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error", + "Related contact c-j: save failed" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-9" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-9", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-9", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-9", + "_limit": 500 + } + } + ], + [ + "log.info", + "👥 PROCESSING RELATED CONTACT PERSONS", + { + "app": "stackiq", + "organizationId": "org-9", + "contactCount": 1 + } + ], + [ + "entity.setObject", + "c-j", + { + "email": "j@x.nl", + "organization": "org-9" + } + ], + [ + "objects.saveObject", + { + "object": { + "email": "j@x.nl", + "organization": "org-9" + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "c-j", + "silent": true, + "_validation": false + } + ], + [ + "log.error", + "[FLOW] Failed to process related contact person", + { + "organizationId": "org-9", + "contactId": "c-j", + "exception": "save failed" + } + ] + ] + }, + "related: search throws": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error", + "Failed to process related contact persons: search failed" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-10" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.info", + "[FLOW] Searching for related contact persons with organisatie field", + { + "organizationId": "org-10", + "register": "5", + "contactSchema": "9", + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-10", + "_limit": 500 + } + } + ], + [ + "objects.searchObjects", + { + "query": { + "@self": { + "register": 5, + "schema": 9 + }, + "organization": "org-10", + "_limit": 500 + } + } + ], + [ + "log.error", + "[FLOW] Failed to process related contact persons", + { + "organizationId": "org-10", + "exception": "search failed", + "file": "(file)", + "line": "(line)" + } + ] + ] + }, + "related: settings throw": { + "stats": { + "contactPersonsProcessed": 0, + "usersCreated": 0, + "usersUpdated": 0, + "errors": [ + "earlier error", + "Failed to process related contact persons: settings down" + ] + }, + "trace": [ + [ + "log.debug", + "Finding related contact persons", + { + "app": "stackiq", + "organizationId": "org-11" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.error", + "[FLOW] Failed to process related contact persons", + { + "organizationId": "org-11", + "exception": "settings down", + "file": "(file)", + "line": "(line)" + } + ] + ] + } +} From 0d62d74f1953f43391d705d404a6f255f608f556 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 15:01:50 +0100 Subject: [PATCH 04/10] refactor(organization-sync): split the related contact person sync into find, fetch and process steps processRelatedContactPersons drops its unused organisation object parameter and hands the work to findRelatedContacts, contactsFromOrganisationList, fetchListedContact, processRelatedContact and completeRelatedContact, each with at most two parameters (the sync run array, stats held by reference). Lookups, saves, log lines and stats are unchanged (golden of the previous commit). Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/OrganizationSyncService.php | 518 ++++++++++++++---------- 1 file changed, 293 insertions(+), 225 deletions(-) diff --git a/lib/Service/OrganizationSyncService.php b/lib/Service/OrganizationSyncService.php index f8dd147d..20d41cc6 100644 --- a/lib/Service/OrganizationSyncService.php +++ b/lib/Service/OrganizationSyncService.php @@ -1761,11 +1761,7 @@ public function processSpecificOrganization($organizationObject): array { ] ); - $this->processRelatedContactPersons( - organizationUuid: $organizationUuid, - organizationObject: $organizationObject, - stats: $stats - ); + $this->processRelatedContactPersons(organizationUuid: $organizationUuid, stats: $stats); }//end if if (empty($organisationEntity) === true) { @@ -1956,14 +1952,11 @@ private function processNestedContactPersons($organizationObject, array &$stats) * Process related contactpersoon objects that have this organization in their organisation property * * @param string $organizationUuid The organization UUID. - * @param \OCA\OpenRegister\Db\ObjectEntity $organizationObject The organization object. - * @param array $stats The statistics array to update. + * @param array $stats The statistics array to update. * * @return void - * - * @SuppressWarnings(PHPMD.UnusedFormalParameter) $organizationObject reserved for future contact person enrichment */ - private function processRelatedContactPersons(string $organizationUuid, $organizationObject, array &$stats): void { + private function processRelatedContactPersons(string $organizationUuid, array &$stats): void { try { $this->logger->debug( 'Finding related contact persons', @@ -1975,274 +1968,349 @@ private function processRelatedContactPersons(string $organizationUuid, $organiz // Get configuration. $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); - $register = ($voorzieningenConfig['register'] ?? ''); - $contactSchema = ($voorzieningenConfig['contactpersoon_schema'] ?? ''); + $run = [ + 'organizationUuid' => $organizationUuid, + 'register' => ($voorzieningenConfig['register'] ?? ''), + 'contactSchema' => ($voorzieningenConfig['contactpersoon_schema'] ?? ''), + ]; - if (empty($register) === true || empty($contactSchema) === true) { + if (empty($run['register']) === true || empty($run['contactSchema']) === true) { $this->logger->warning( '[FLOW] Related contact processing skipped - configuration missing', [ 'organizationId' => $organizationUuid, - 'register' => $register, - 'contactSchema' => $contactSchema, + 'register' => $run['register'], + 'contactSchema' => $run['contactSchema'], ] ); return; } - // Find all contactpersoon objects that have this organization in their organisation property. + $relatedContacts = $this->findRelatedContacts(run: $run); + if (empty($relatedContacts) === true) { + return; + } - // Search for contactpersoon objects with this organization reference. - // Try both 'organization' and 'organisation' field names. - $query = [ - '@self' => [ - 'register' => (int)$register, - 'schema' => (int)$contactSchema, - ], - 'organization' => $organizationUuid, - '_limit' => 500, - ]; + $this->logger->info( + '👥 PROCESSING RELATED CONTACT PERSONS', + [ + 'app' => 'stackiq', + 'organizationId' => $organizationUuid, + 'contactCount' => count($relatedContacts), + ] + ); + + $run['stats'] = &$stats; + foreach ($relatedContacts as $contactObject) { + $this->processRelatedContact(contactObject: $contactObject, run: $run); + } + } catch (\Exception $e) { + $this->logger->error( + '[FLOW] Failed to process related contact persons', + [ + 'organizationId' => $organizationUuid, + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ] + ); + $stats['errors'][] = 'Failed to process related contact persons: ' . $e->getMessage(); + }//end try + + }//end processRelatedContactPersons() + + /** + * Find the contactpersoon objects of an organisation: by their organization field, then by + * their organisation field, then from the organisation's own contactpersonen array. + * + * @param array $run The sync run: `organizationUuid`, `register` and `contactSchema`. + * + * @return array The related contact objects, empty when none are found + */ + private function findRelatedContacts(array $run): array { + $organizationUuid = $run['organizationUuid']; + + // Search for contactpersoon objects with this organization reference. + // Try both 'organization' and 'organisation' field names. + $query = [ + '@self' => [ + 'register' => (int)$run['register'], + 'schema' => (int)$run['contactSchema'], + ], + 'organization' => $organizationUuid, + '_limit' => 500, + ]; + + $this->logger->info( + '[FLOW] Searching for related contact persons with organisatie field', + [ + 'organizationId' => $organizationUuid, + 'register' => $run['register'], + 'contactSchema' => $run['contactSchema'], + 'query' => $query, + ] + ); + + $relatedContacts = $this->objectService->searchObjects($query); + + // If not found, try with 'organisation' field. + if (empty($relatedContacts) === true) { + $query['organisation'] = $organizationUuid; + unset($query['organization']); $this->logger->info( - '[FLOW] Searching for related contact persons with organisatie field', + '[FLOW] Retrying search with organisation field', [ 'organizationId' => $organizationUuid, - 'register' => $register, - 'contactSchema' => $contactSchema, 'query' => $query, ] ); $relatedContacts = $this->objectService->searchObjects($query); + } - // If not found, try with 'organisation' field. - if (empty($relatedContacts) === true) { - $query['organisation'] = $organizationUuid; - unset($query['organization']); - - $this->logger->info( - '[FLOW] Retrying search with organisation field', - [ - 'organizationId' => $organizationUuid, - 'query' => $query, - ] - ); + if (empty($relatedContacts) === false) { + return $relatedContacts; + } - $relatedContacts = $this->objectService->searchObjects($query); - } + // Fallback: fetch contacts by UUID from the org's contactpersonen array. + // This handles the case where contacts were linked via inversedBy (org → contact). + // but the contact's own organisatie field was never set. + $this->logger->info( + '[FLOW] No contacts found by organisatie field, checking org contactpersonen array', + ['organizationId' => $organizationUuid] + ); - if (empty($relatedContacts) === true) { - // Fallback: fetch contacts by UUID from the org's contactpersonen array. - // This handles the case where contacts were linked via inversedBy (org → contact). - // but the contact's own organisatie field was never set. - $this->logger->info( - '[FLOW] No contacts found by organisatie field, checking org contactpersonen array', - ['organizationId' => $organizationUuid] - ); + $relatedContacts = $this->contactsFromOrganisationList(run: $run); + if (empty($relatedContacts) === true) { + $this->logger->info( + '[FLOW] No related contact persons found (including contactpersonen fallback)', + ['organizationId' => $organizationUuid] + ); + } - // Re-fetch the org object without _extend to get raw UUIDs. - try { - $voorzieningenConfig2 = $this->settingsService->getVoorzieningenConfig(); - $orgRegister = ($voorzieningenConfig2['register'] ?? ''); - $orgSchema = ($voorzieningenConfig2['organisatie_schema'] ?? ''); - - $rawOrgObject = $this->objectService->find( - id: $organizationUuid, - register: $orgRegister, - schema: $orgSchema, - _rbac: false, - _multitenancy: false - ); + return $relatedContacts; + }//end findRelatedContacts() - $rawOrgData = []; - if ($rawOrgObject !== null) { - $rawOrgData = $rawOrgObject->getObject(); - } + /** + * The contact objects listed in the organisation's raw contactpersonen array. + * + * @param array $run The sync run: `organizationUuid`, `register` and `contactSchema`. + * + * @return array The contact objects that could be fetched + */ + private function contactsFromOrganisationList(array $run): array { + $organizationUuid = $run['organizationUuid']; + $contacts = []; - $contactUuids = ($rawOrgData['contactpersonen'] ?? []); + // Re-fetch the org object without _extend to get raw UUIDs. + try { + $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); + $rawOrgObject = $this->objectService->find( + id: $organizationUuid, + register: ($voorzieningenConfig['register'] ?? ''), + schema: ($voorzieningenConfig['organisatie_schema'] ?? ''), + _rbac: false, + _multitenancy: false + ); - $this->logger->info( - '[FLOW] Raw org contactpersonen array', - [ - 'organizationId' => $organizationUuid, - 'contactpersonen' => $contactUuids, - 'count' => count($contactUuids), - ] - ); + $rawOrgData = []; + if ($rawOrgObject !== null) { + $rawOrgData = $rawOrgObject->getObject(); + } - if (empty($contactUuids) === false) { - foreach ($contactUuids as $contactUuid) { - if (is_string($contactUuid) === false || empty($contactUuid) === true) { - continue; - } + $contactUuids = ($rawOrgData['contactpersonen'] ?? []); - try { - $contactObj = $this->objectService->find( - id: $contactUuid, - register: $register, - schema: $contactSchema, - _rbac: false, - _multitenancy: false - ); - if ($contactObj !== null) { - $relatedContacts[] = $contactObj; - } - } catch (\Exception $fetchEx) { - $this->logger->warning( - '[FLOW] Could not fetch contact from contactpersonen array', - [ - 'organizationId' => $organizationUuid, - 'contactUuid' => $contactUuid, - 'exception' => $fetchEx->getMessage(), - ] - ); - }//end try - }//end foreach - }//end if - } catch (\Exception $orgEx) { - $this->logger->warning( - '[FLOW] Could not re-fetch org for contactpersonen fallback', - [ - 'organizationId' => $organizationUuid, - 'exception' => $orgEx->getMessage(), - ] - ); - }//end try + $this->logger->info( + '[FLOW] Raw org contactpersonen array', + [ + 'organizationId' => $organizationUuid, + 'contactpersonen' => $contactUuids, + 'count' => count($contactUuids), + ] + ); - if (empty($relatedContacts) === true) { - $this->logger->info( - '[FLOW] No related contact persons found (including contactpersonen fallback)', - ['organizationId' => $organizationUuid] - ); - return; + foreach ($contactUuids as $contactUuid) { + if (is_string($contactUuid) === false || empty($contactUuid) === true) { + continue; } - }//end if - $this->logger->info( - '👥 PROCESSING RELATED CONTACT PERSONS', + $contactObj = $this->fetchListedContact(contactUuid: $contactUuid, run: $run); + if ($contactObj !== null) { + $contacts[] = $contactObj; + } + } + } catch (\Exception $orgEx) { + $this->logger->warning( + '[FLOW] Could not re-fetch org for contactpersonen fallback', [ - 'app' => 'stackiq', 'organizationId' => $organizationUuid, - 'contactCount' => count($relatedContacts), + 'exception' => $orgEx->getMessage(), ] ); + }//end try - foreach ($relatedContacts as $contactObject) { - try { - $contactUuid = $contactObject->getUuid(); - $contactData = $contactObject->getObject(); + return $contacts; + }//end contactsFromOrganisationList() - // Check if contact data is complete (has email). - $email = ($contactData['email'] ?? $contactData['e-mailadres'] ?? ''); - if (empty($email) === true && empty($contactUuid) === false) { - // Re-fetch the full contact object if email is missing. - $this->logger->info( - '[FLOW] Contact data incomplete, re-fetching full object', - [ - 'organizationId' => $organizationUuid, - 'contactId' => $contactUuid, - ] - ); + /** + * Fetch one contact listed in an organisation's contactpersonen array. + * + * @param string $contactUuid The contact uuid. + * @param array $run The sync run: `organizationUuid`, `register` and `contactSchema`. + * + * @return object|null The contact object, or null when it cannot be fetched + */ + private function fetchListedContact(string $contactUuid, array $run): ?object { + try { + return $this->objectService->find( + id: $contactUuid, + register: $run['register'], + schema: $run['contactSchema'], + _rbac: false, + _multitenancy: false + ); + } catch (\Exception $fetchEx) { + $this->logger->warning( + '[FLOW] Could not fetch contact from contactpersonen array', + [ + 'organizationId' => $run['organizationUuid'], + 'contactUuid' => $contactUuid, + 'exception' => $fetchEx->getMessage(), + ] + ); + return null; + }//end try + }//end fetchListedContact() - try { - $fullContactObject = $this->objectService->find( - id: $contactUuid, - register: $register, - schema: $contactSchema, - _rbac: false, - _multitenancy: false - ); - if ($fullContactObject !== null) { - $contactObject = $fullContactObject; - $contactData = $contactObject->getObject(); - $email = ($contactData['email'] ?? $contactData['e-mailadres'] ?? ''); - } - } catch (\Exception $e) { - $this->logger->warning( - '[FLOW] Failed to re-fetch contact object', - [ - 'organizationId' => $organizationUuid, - 'contactId' => $contactUuid, - 'exception' => $e->getMessage(), - ] - ); - }//end try - }//end if + /** + * Process one related contact: complete it, give it its organisation, then sync it. + * + * @param object $contactObject The contact object. + * @param array $run The sync run: `organizationUuid`, `register`, `contactSchema` and, + * held by reference, `stats`. + * + * @return void + */ + private function processRelatedContact(object $contactObject, array $run): void { + $organizationUuid = $run['organizationUuid']; + try { + $contactUuid = $contactObject->getUuid(); + $contactObject = $this->completeRelatedContact(contactObject: $contactObject, run: $run); + $contactData = $contactObject->getObject(); + $email = ($contactData['email'] ?? $contactData['e-mailadres'] ?? ''); - // Ensure the contact has the organisatie field set before processing. - // (may be missing when contacts were linked via inversedBy from the org side). - if (empty($contactData['organization']) === true) { - $contactData['organization'] = $organizationUuid; - $contactObject->setObject($contactData); - // Published contract instead of OpenRegister's Db layer; see - // ContactpersonenController for the reasoning. - $this->objectService->saveObject( - object: $contactData, - register: $contactObject->getRegister(), - schema: $contactObject->getSchema(), - uuid: $contactObject->getUuid(), - silent: true, - _validation: false - ); - $this->logger->info( - '[FLOW] Set missing organisatie field on related contact', - [ - 'contactId' => $contactUuid, - 'organizationUuid' => $organizationUuid, - ] - ); - } + // Ensure the contact has the organisatie field set before processing. + // (may be missing when contacts were linked via inversedBy from the org side). + if (empty($contactData['organization']) === true) { + $contactData['organization'] = $organizationUuid; + $contactObject->setObject($contactData); + // Published contract instead of OpenRegister's Db layer; see + // ContactpersonenController for the reasoning. + $this->objectService->saveObject( + object: $contactData, + register: $contactObject->getRegister(), + schema: $contactObject->getSchema(), + uuid: $contactObject->getUuid(), + silent: true, + _validation: false + ); + $this->logger->info( + '[FLOW] Set missing organisatie field on related contact', + [ + 'contactId' => $contactUuid, + 'organizationUuid' => $organizationUuid, + ] + ); + }//end if - $contactEmailValue = $email; - if ($contactEmailValue === false || $contactEmailValue === '' || $contactEmailValue === null) { - $contactEmailValue = 'unknown'; - } + if ($email === false || $email === '' || $email === null) { + $email = 'unknown'; + } - $this->logger->info( - '[FLOW] Processing related contact person', - [ - 'organizationId' => $organizationUuid, - 'contactId' => $contactUuid, - 'contactEmail' => $contactEmailValue, - ] - ); + $this->logger->info( + '[FLOW] Processing related contact person', + [ + 'organizationId' => $organizationUuid, + 'contactId' => $contactUuid, + 'contactEmail' => $email, + ] + ); - // Process the contact person through processSpecificContactPerson. - $contactStats = $this->processSpecificContactPerson(contactObject: $contactObject); + // Process the contact person through processSpecificContactPerson. + $contactStats = $this->processSpecificContactPerson(contactObject: $contactObject); - // Merge stats. - $stats['contactPersonsProcessed'] += ($contactStats['contactPersonsProcessed'] ?? 0); - $stats['usersCreated'] += ($contactStats['usersCreated'] ?? 0); - $stats['usersUpdated'] += ($contactStats['usersUpdated'] ?? 0); - if (empty($contactStats['errors']) === false) { - $stats['errors'] = array_merge($stats['errors'], $contactStats['errors']); - } - } catch (\Exception $e) { - $this->logger->error( - '[FLOW] Failed to process related contact person', - [ - 'organizationId' => $organizationUuid, - 'contactId' => $contactObject->getUuid(), - 'exception' => $e->getMessage(), - ] - ); - $stats['errors'][] = "Related contact {$contactObject->getUuid()}: " . $e->getMessage(); - }//end try - }//end foreach + // Merge stats. + $run['stats']['contactPersonsProcessed'] += ($contactStats['contactPersonsProcessed'] ?? 0); + $run['stats']['usersCreated'] += ($contactStats['usersCreated'] ?? 0); + $run['stats']['usersUpdated'] += ($contactStats['usersUpdated'] ?? 0); + if (empty($contactStats['errors']) === false) { + $run['stats']['errors'] = array_merge($run['stats']['errors'], $contactStats['errors']); + } } catch (\Exception $e) { $this->logger->error( - '[FLOW] Failed to process related contact persons', + '[FLOW] Failed to process related contact person', [ 'organizationId' => $organizationUuid, + 'contactId' => $contactObject->getUuid(), 'exception' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), ] ); - $stats['errors'][] = 'Failed to process related contact persons: ' . $e->getMessage(); + $run['stats']['errors'][] = "Related contact {$contactObject->getUuid()}: " . $e->getMessage(); }//end try + }//end processRelatedContact() - }//end processRelatedContactPersons() + /** + * A related contact with its full data: one without an email is fetched again by uuid. + * + * @param object $contactObject The contact object as found. + * @param array $run The sync run: `organizationUuid`, `register` and `contactSchema`. + * + * @return object The re-fetched contact, or the one given when it is complete or cannot be fetched + */ + private function completeRelatedContact(object $contactObject, array $run): object { + $contactUuid = $contactObject->getUuid(); + $contactData = $contactObject->getObject(); + + // Check if contact data is complete (has email). + $email = ($contactData['email'] ?? $contactData['e-mailadres'] ?? ''); + if (empty($email) === false || empty($contactUuid) === true) { + return $contactObject; + } + + // Re-fetch the full contact object if email is missing. + $this->logger->info( + '[FLOW] Contact data incomplete, re-fetching full object', + [ + 'organizationId' => $run['organizationUuid'], + 'contactId' => $contactUuid, + ] + ); + + try { + $fullContactObject = $this->objectService->find( + id: $contactUuid, + register: $run['register'], + schema: $run['contactSchema'], + _rbac: false, + _multitenancy: false + ); + if ($fullContactObject !== null) { + return $fullContactObject; + } + } catch (\Exception $e) { + $this->logger->warning( + '[FLOW] Failed to re-fetch contact object', + [ + 'organizationId' => $run['organizationUuid'], + 'contactId' => $contactUuid, + 'exception' => $e->getMessage(), + ] + ); + }//end try + + return $contactObject; + }//end completeRelatedContact() /** * Create or update a contact person object and its user account. From b3a48c0fac8590c768eb9e17731b4b04825bee7b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 15:04:17 +0100 Subject: [PATCH 05/10] test(organization-sync): golden for ensuring the organisation entity of an organisatie object The OrganisationMapper stub gains findBySlug, as on OpenRegister's mapper. Assisted-by: ClaudeCode:claude-opus-5-5 --- tests/Stubs/Db/OrganisationMapper.php | 9 + .../OrganizationSyncEntityGoldenTest.php | 636 ++++++ .../golden/organizationSyncEntity.json | 1852 +++++++++++++++++ 3 files changed, 2497 insertions(+) create mode 100644 tests/Unit/Service/OrganizationSyncEntityGoldenTest.php create mode 100644 tests/Unit/Service/golden/organizationSyncEntity.json diff --git a/tests/Stubs/Db/OrganisationMapper.php b/tests/Stubs/Db/OrganisationMapper.php index 140cdefb..e7618229 100644 --- a/tests/Stubs/Db/OrganisationMapper.php +++ b/tests/Stubs/Db/OrganisationMapper.php @@ -31,6 +31,15 @@ abstract class OrganisationMapper { */ abstract public function findByUuid(string $uuid): Organisation; + /** + * Find an organisation by slug. + * + * @param string $slug The organisation slug. + * + * @return Organisation + */ + abstract public function findBySlug(string $slug): Organisation; + /** * Persist an organisation entity. * diff --git a/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php b/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php new file mode 100644 index 00000000..4d9a86a6 --- /dev/null +++ b/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php @@ -0,0 +1,636 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/organization-sync/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\OpenRegister\Contract\ObjectEntityInterface; +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\Stackiq\Service\OrganisatieService; +use OCA\Stackiq\Service\OrganizationSyncService; +use OCA\Stackiq\Service\SettingsService; +use OCA\Stackiq\Service\Stackiq\ContactPersonHandler; +use OCA\Stackiq\Service\SymfonyEmailService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IUser; +use PHPUnit\Framework\TestCase; +use Psr\Log\AbstractLogger; + +/** + * Golden cases for the organisation entity sync. + * + * @covers \OCA\Stackiq\Service\OrganizationSyncService + * + * @spec openspec/specs/organization-sync/spec.md + */ +class OrganizationSyncEntityGoldenTest extends TestCase { + + /** + * Everything the doubles saw, in order. + * + * @var array + */ + private array $trace = []; + + /** + * Per-case behaviour of the doubles. + * + * @var array + */ + private array $plan = []; + + /** + * The golden file. + * + * @return string + */ + private static function golden(): string { + return __DIR__ . '/golden/organizationSyncEntity.json'; + + }//end golden() + + /** + * A contact or organisation object double that records its writes. + * + * @param array $object The object data. + * @param string $uuid The uuid. + * + * @return ObjectEntityInterface + */ + private function entity(array $object, string $uuid): ObjectEntityInterface { + $trace = &$this->trace; + return new class($object, $uuid, $trace) implements ObjectEntityInterface { + /** + * The owner set on the entity. + * + * @var string|null + */ + private ?string $owner = null; + + /** + * The organisation set on the entity. + * + * @var string|null + */ + private ?string $organisation = 'org-of-entity'; + + /** + * Build the double. + * + * @param array $object The object data. + * @param string $uuid The uuid. + * @param array $trace The shared trace. + */ + public function __construct(private array $object, private string $uuid, private array &$trace) { + } + + /** + * @return mixed + */ + public function getId() { + return 7; + } + + /** + * @return string|null + */ + public function getUuid(): ?string { + return $this->uuid; + } + + /** + * @return array + */ + public function getObject(): array { + return $this->object; + } + + /** + * @return string|null + */ + public function getRegister(): ?string { + return 'reg-entity'; + } + + /** + * @return string|null + */ + public function getSchema(): ?string { + return 'schema-entity'; + } + + /** + * @return string|null + */ + public function getOrganisation(): ?string { + return $this->organisation; + } + + /** + * @return string|null + */ + public function getOwner(): ?string { + return $this->owner; + } + + /** + * @param mixed $object The data. + * + * @return void + */ + public function setObject($object=null) { + $this->trace[] = ['entity.setObject', $this->uuid, $object]; + $this->object = $object; + } + + /** + * @param string|null $owner The owner. + * + * @return void + */ + public function setOwner(?string $owner): void { + $this->trace[] = ['entity.setOwner', $this->uuid, $owner]; + $this->owner = $owner; + } + + /** + * @param string|null $organisation The organisation. + * + * @return void + */ + public function setOrganisation(?string $organisation): void { + $this->trace[] = ['entity.setOrganisation', $this->uuid, $organisation]; + $this->organisation = $organisation; + } + + /** + * @return mixed + */ + public function jsonSerialize(): mixed { + return $this->object; + } + + /** + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + $this->trace[] = ['entity.' . $name, $this->uuid, $args]; + return null; + } + }; + + }//end entity() + + /** + * Arguments by parameter name, leaving out the ones left at their default. + * + * @param string $method The ObjectServiceInterface method. + * @param array $args The positional arguments the double received. + * + * @return array + */ + private static function named(string $method, array $args): array { + $named = []; + foreach ((new \ReflectionMethod(ObjectServiceInterface::class, $method))->getParameters() as $i => $param) { + if (array_key_exists($i, $args) === false) { + continue; + } + + if ($param->isDefaultValueAvailable() === true && $param->getDefaultValue() === $args[$i]) { + continue; + } + + $value = $args[$i]; + if ($value instanceof ObjectEntityInterface) { + $value = 'entity:' . $value->getUuid(); + } + + $named[$param->getName()] = $value; + } + + return $named; + + }//end named() + + /** + * Build the service with recording doubles driven by $this->plan. + * + * @return OrganizationSyncService + */ + private function service(): OrganizationSyncService { + $trace = &$this->trace; + $logger = new class($trace) extends AbstractLogger { + /** + * @param array $trace The shared trace. + */ + public function __construct(private array &$trace) { + } + + /** + * @param mixed $level The level. + * @param string|\Stringable $message The message. + * @param array $context The context. + * + * @return void + */ + public function log($level, string|\Stringable $message, array $context=[]): void { + foreach (['trace', 'file', 'line'] as $volatile) { + if (array_key_exists($volatile, $context) === true) { + $context[$volatile] = '(' . $volatile . ')'; + } + } + + $this->trace[] = ['log.' . $level, (string) $message, $context]; + } + }; + + $objects = $this->createMock(originalClassName: ObjectServiceInterface::class); + $objects->method('find')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.find', self::named(method: 'find', args: $args)]; + $answer = array_shift($this->plan['find']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + $objects->method('saveObject')->willReturnCallback( + function (...$args) { + $this->trace[] = ['objects.saveObject', self::named(method: 'saveObject', args: $args)]; + $answer = array_shift($this->plan['save']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return ($answer ?? $this->entity(object: ['saved' => true], uuid: 'saved-uuid')); + } + ); + + $mapper = $this->createMock(originalClassName: OrganisationMapper::class); + $mapper->method('findBySlug')->willReturnCallback( + function (string $slug) { + $this->trace[] = ['mapper.findBySlug', $slug]; + $answer = array_shift($this->plan['slug']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + $mapper->method('save')->willReturnCallback( + function ($entity) { + $this->trace[] = ['mapper.save', $entity->getUuid(), $entity->getActive()]; + return $entity; + } + ); + $mapper->method('findByUuid')->willReturnCallback( + function (string $uuid) { + $this->trace[] = ['mapper.findByUuid', $uuid]; + $answer = array_shift($this->plan['org']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + + $handler = $this->createMock(originalClassName: ContactPersonHandler::class); + $handler->method('isFirstContactForOrganization')->willReturnCallback( + function ($contact, $data) { + $this->trace[] = ['handler.isFirst', $contact->getUuid(), $data]; + return $this->plan['first'] ?? false; + } + ); + $handler->method('createUserAccount')->willReturnCallback( + function ($contact, $isFirst) { + $this->trace[] = ['handler.createUserAccount', $contact->getUuid(), $isFirst]; + return ($this->plan['user'] ?? null); + } + ); + $handler->method('addUserToOrganizationEntity')->willReturnCallback( + function (...$args) { + $args[0] = $args[0]->getUuid(); + $this->trace[] = ['handler.addUserToOrganizationEntity', $args]; + return true; + } + ); + + $settings = $this->createMock(originalClassName: SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturnCallback( + function () { + $this->trace[] = ['settings.getVoorzieningenConfig']; + $answer = ($this->plan['config'] ?? ['register' => 'reg-v', 'organisatie_schema' => 'org-schema']); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + + // Built without the constructor: it type-hints IDBConnection, whose + // mock needs Doctrine, and the methods under test never touch it. + $reflection = new \ReflectionClass(OrganizationSyncService::class); + $service = $reflection->newInstanceWithoutConstructor(); + $properties = [ + 'logger' => $logger, + 'settingsService' => $settings, + 'contactpersonHandler' => $handler, + 'objectService' => $objects, + 'organisationMapper' => $mapper, + 'organisationService' => $this->organisationService(), + 'emailService' => $this->emailService(), + ]; + foreach ($properties as $name => $value) { + $reflection->getProperty($name)->setValue($service, $value); + } + + return $service; + + }//end service() + + /** + * An organisation service double that records the organisations it creates. + * + * @return OrganisatieService + */ + private function organisationService(): OrganisatieService { + $service = $this->createMock(originalClassName: OrganisatieService::class); + $service->method('createOrganisationInOpenRegister')->willReturnCallback( + function (array $data) { + $this->trace[] = ['organisations.create', $data]; + return array_key_exists('created', $this->plan) === true ? $this->plan['created'] : $this->orgEntity(uuid: 'new-entity', active: true); + } + ); + return $service; + + }//end organisationService() + + /** + * An organisation entity double. + * + * @param mixed $active What getActive() answers, or a Throwable it throws. + * + * @return Organisation + */ + private static function organisation(mixed $active): Organisation { + return new class($active) extends Organisation { + /** + * @param mixed $active The answer. + */ + public function __construct(private mixed $active) { + } + + /** + * @return mixed + */ + public function getActive(): mixed { + if ($this->active instanceof \Throwable) { + throw $this->active; + } + + return $this->active; + } + + /** + * @return string + */ + public function getName(): string { + return 'Org'; + } + + /** + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + return null; + } + }; + + }//end organisation() + + /** + * A user double. + * + * @param string $uid The uid. + * + * @return IUser + */ + private function user(string $uid): IUser { + $user = $this->createMock(originalClassName: IUser::class); + $user->method('getUID')->willReturn($uid); + return $user; + + }//end user() + + /** + * An e-mail service double that records the mails and answers per plan. + * + * @return SymfonyEmailService + */ + private function emailService(): SymfonyEmailService { + $mail = $this->createMock(originalClassName: SymfonyEmailService::class); + foreach (['sendOrganizationRegistrationEmail', 'sendOrganizationActivationEmail'] as $method) { + $mail->method($method)->willReturnCallback( + function (array $data) use ($method) { + $this->trace[] = ['mail.' . $method, $data['name'] ?? null]; + $answer = ($this->plan['mail'] ?? true); + if ($answer instanceof \Throwable) { + throw $answer; + } + + return $answer; + } + ); + } + + return $mail; + + }//end emailService() + + /** + * An organisation entity double that records its writes. + * + * @param string $uuid The entity uuid. + * @param bool $active Whether it is active. + * + * @return Organisation + */ + public function orgEntity(string $uuid, bool $active): Organisation { + $trace = &$this->trace; + return new class($uuid, $active, $trace) extends Organisation { + /** + * @param string $entityUuid The uuid. + * @param bool $isActive Whether it is active. + * @param array $trace The shared trace. + */ + public function __construct(private string $entityUuid, private bool $isActive, private array &$trace) { + } + + /** + * @return int|null + */ + public function getId(): ?int { + return 41; + } + + /** + * @return string + */ + public function getUuid(): string { + return $this->entityUuid; + } + + /** + * @param string $uuid The uuid. + * + * @return void + */ + public function setUuid(string $uuid): void { + $this->trace[] = ['entity.setUuid', $this->entityUuid, $uuid]; + $this->entityUuid = $uuid; + } + + /** + * @return bool + */ + public function getActive(): bool { + return $this->isActive; + } + + /** + * @param bool $active Whether it is active. + * + * @return void + */ + public function setActive(bool $active): void { + $this->trace[] = ['entity.setActive', $this->entityUuid, $active]; + $this->isActive = $active; + } + + /** + * @return string + */ + public function getName(): string { + return 'Entity ' . $this->entityUuid; + } + }; + + }//end orgEntity() + + /** + * The cases: the organisatie object, the e-mail flag and the doubles' answers. + * + * @return array + */ + public static function entityCases(): array { + $missing = fn () => new \OCP\AppFramework\Db\DoesNotExistException('missing'); + return [ + 'full fetch fails, entity in sync' => [['name' => 'Gemeente A', 'status' => 'actief'], true, fn ($t) => ['find' => [new \RuntimeException('find failed')], 'org' => [$t->orgEntity('o-1', true)]]], + 'full object replaces the given one' => [['name' => 'A'], true, fn ($t) => ['find' => [$t->entity(['name' => 'Gemeente B', 'status' => 'Active', '@self' => ['owner' => 'o-1'], 'organisation' => 'o-1'], 'o-1')], 'org' => [$t->orgEntity('o-1', true)]]], + 'existing entity deactivated' => [['name' => 'Gemeente C', 'status' => 'inactief'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', true)]]], + 'existing entity activated, mail sent' => [['name' => 'Gemeente D'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', false)]]], + 'existing entity activated, mail not sent' => [['name' => 'Gemeente E', 'status' => 'ACTIVE'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', false)], 'mail' => false]], + 'existing entity activated, mail throws' => [['name' => 'Gemeente E2'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', false)], 'mail' => new \RuntimeException('smtp down')]], + 'existing entity activated, no mails' => [['name' => 'Gemeente F'], false, fn ($t) => ['org' => [$t->orgEntity('o-1', false)]]], + 'adopted by slug' => [['name' => 'Gemeente G & Zn!', 'status' => 'actief'], true, fn ($t) => ['org' => [$missing()], 'slug' => [$t->orgEntity('o-old', true)]]], + 'not found, created with mail' => [['name' => 'Gemeente H'], true, fn ($t) => ['org' => [$missing()], 'slug' => [$missing()]]], + 'not found, created, mail not sent' => [['name' => 'Gemeente H2'], true, fn ($t) => ['org' => [$missing()], 'slug' => [$missing()], 'mail' => false]], + 'not found, created without mail' => [['name' => 'Gemeente I'], false, fn ($t) => ['org' => [$missing()], 'slug' => [$missing()]]], + 'no name, created' => [['status' => 'actief'], true, fn ($t) => ['org' => [$missing()]]], + 'creation fails' => [['name' => 'Gemeente J'], true, fn ($t) => ['org' => [$missing()], 'slug' => [$missing()], 'created' => null]], + 'mapper fails' => [['name' => 'Gemeente K'], true, fn ($t) => ['org' => [new \RuntimeException('db down')]]], + 'owner save fails' => [['name' => 'Gemeente L'], true, fn ($t) => ['org' => [$t->orgEntity('o-1', true)], 'save' => [new \RuntimeException('save failed')]]], + 'settings fail' => [['name' => 'Gemeente M'], true, fn ($t) => ['config' => new \RuntimeException('settings down')]], + ]; + + }//end entityCases() + + /** + * Each organisation entity case matches its recording. + * + * @param array $object The organisatie object data. + * @param bool $sendEmails Whether e-mails are sent. + * @param \Closure $plan Builds the doubles' answers. + * + * @return void + * + * @dataProvider entityCases + */ + public function testEnsureEntity(array $object, bool $sendEmails, \Closure $plan): void { + $this->trace = []; + $this->plan = array_merge(['find' => [null], 'save' => [], 'org' => [], 'slug' => []], $plan($this)); + $service = $this->service(); + $stats = ['entitiesCreated' => 0, 'entitiesUpdated' => 0]; + $entity = $service->ensureOrganisationEntityPublic($this->entity(object: $object, uuid: 'o-1'), $stats, $sendEmails); + $returned = null; + if ($entity !== null) { + $returned = [$entity->getUuid(), $entity->getActive()]; + } + + $this->check(key: 'entity: ' . $this->dataName(), value: ['returned' => $returned, 'stats' => $stats, 'trace' => $this->trace]); + + }//end testEnsureEntity() + + /** + * Compare a case with the golden file, or record it. + * + * @param string $key The case. + * @param array $value What the case observed. + * + * @return void + */ + private function check(string $key, array $value): void { + $json = json_decode(json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR), true); + $file = self::golden(); + $saved = []; + if (file_exists($file) === true) { + $saved = json_decode((string) file_get_contents($file), true); + } + + if (getenv('RECORD_GOLDEN') !== false) { + $saved[$key] = $json; + if (is_dir(dirname($file)) === false) { + mkdir(dirname($file), 0777, true); + } + + file_put_contents($file, json_encode($saved, (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . "\n"); + $this->addToAssertionCount(count: 1); + return; + } + + $this->assertArrayHasKey(key: $key, array: $saved, message: 'No golden for ' . $key); + $this->assertSame(expected: $saved[$key], actual: $json); + + }//end check() + +}//end class diff --git a/tests/Unit/Service/golden/organizationSyncEntity.json b/tests/Unit/Service/golden/organizationSyncEntity.json new file mode 100644 index 00000000..02b4560a --- /dev/null +++ b/tests/Unit/Service/golden/organizationSyncEntity.json @@ -0,0 +1,1852 @@ +{ + "entity: full fetch fails, entity in sync": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.warning", + "Could not fetch full organisation object, using provided object", + { + "organisatieId": "o-1", + "error": "find failed" + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente A", + "status": "actief" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": false + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente A", + "status": "actief", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente A", + "status": "actief", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: full object replaces the given one": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente B", + "status": "Active" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": false + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "log.debug", + "OrganizationSyncService: Owner and organisation already set correctly, skipping update", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1" + } + ] + ] + }, + "entity: existing entity deactivated": { + "returned": [ + "o-1", + false + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente C", + "status": "inactief" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": false, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": true, + "newActive": false + } + ], + [ + "entity.setActive", + "o-1", + false + ], + [ + "mapper.save", + "o-1", + false + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente C", + "status": "inactief", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente C", + "status": "inactief", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: existing entity activated, mail sent": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente D", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": false, + "newActive": true + } + ], + [ + "entity.setActive", + "o-1", + true + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "[FLOW] Sending organization activation email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationActivationEmail", + "Gemeente D" + ], + [ + "log.info", + "📧 Organization activation email sent successfully", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente D", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente D", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: existing entity activated, mail not sent": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente E", + "status": "ACTIVE" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": false, + "newActive": true + } + ], + [ + "entity.setActive", + "o-1", + true + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "[FLOW] Sending organization activation email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationActivationEmail", + "Gemeente E" + ], + [ + "log.info", + "📧 Organization activation email not sent (disabled or not configured)", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente E", + "status": "ACTIVE", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente E", + "status": "ACTIVE", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: existing entity activated, mail throws": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente E2", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": false, + "newActive": true + } + ], + [ + "entity.setActive", + "o-1", + true + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "[FLOW] Sending organization activation email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationActivationEmail", + "Gemeente E2" + ], + [ + "log.warning", + "OrganizationSyncService: Organization activation email failed", + { + "organizationId": "unknown", + "organizationName": "Gemeente E2", + "error": "smtp down" + } + ], + [ + "log.info", + "📧 Organization activation email not sent (disabled or not configured)", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente E2", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente E2", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: existing entity activated, no mails": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente F", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": true + } + ], + [ + "log.debug", + "Updating entity status", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldActive": false, + "newActive": true + } + ], + [ + "entity.setActive", + "o-1", + true + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente F", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente F", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: adopted by slug": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 1 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente G & Zn!", + "status": "actief" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-g-zn" + ], + [ + "log.info", + "OrganizationSyncService: Found existing entity by slug, updating UUID to match object", + { + "app": "stackiq", + "organisatieId": "o-1", + "oldEntityUuid": "o-old", + "slug": "gemeente-g-zn" + } + ], + [ + "entity.setUuid", + "o-old", + "o-1" + ], + [ + "mapper.save", + "o-1", + true + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente G & Zn!", + "status": "actief", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente G & Zn!", + "status": "actief", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "ownerSet": "o-1", + "organisationSet": "o-1" + } + ] + ] + }, + "entity: not found, created with mail": { + "returned": [ + "new-entity", + true + ], + "stats": { + "entitiesCreated": 1, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente H", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-h" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente H" + } + ], + [ + "organisations.create", + { + "name": "Gemeente H" + } + ], + [ + "log.debug", + "New organisation entity created", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "active": true, + "name": "Entity new-entity" + } + ], + [ + "log.info", + "[FLOW] Sending organization registration email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationRegistrationEmail", + "Gemeente H" + ], + [ + "log.info", + "📧 Organization registration email sent successfully", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente H", + "organisation": "new-entity", + "@self": { + "owner": "new-entity", + "organisation": "new-entity" + } + } + ], + [ + "entity.setOwner", + "o-1", + "new-entity" + ], + [ + "entity.setOrganisation", + "o-1", + "new-entity" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente H", + "organisation": "new-entity", + "@self": { + "organisation": "new-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "ownerSet": "new-entity", + "organisationSet": "new-entity" + } + ] + ] + }, + "entity: not found, created, mail not sent": { + "returned": [ + "new-entity", + true + ], + "stats": { + "entitiesCreated": 1, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente H2", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-h2" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente H2" + } + ], + [ + "organisations.create", + { + "name": "Gemeente H2" + } + ], + [ + "log.debug", + "New organisation entity created", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "active": true, + "name": "Entity new-entity" + } + ], + [ + "log.info", + "[FLOW] Sending organization registration email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationRegistrationEmail", + "Gemeente H2" + ], + [ + "log.info", + "📧 Organization registration email not sent (disabled or not configured)", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente H2", + "organisation": "new-entity", + "@self": { + "owner": "new-entity", + "organisation": "new-entity" + } + } + ], + [ + "entity.setOwner", + "o-1", + "new-entity" + ], + [ + "entity.setOrganisation", + "o-1", + "new-entity" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente H2", + "organisation": "new-entity", + "@self": { + "organisation": "new-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "ownerSet": "new-entity", + "organisationSet": "new-entity" + } + ] + ] + }, + "entity: not found, created without mail": { + "returned": [ + "new-entity", + true + ], + "stats": { + "entitiesCreated": 1, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente I", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-i" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente I" + } + ], + [ + "organisations.create", + { + "name": "Gemeente I" + } + ], + [ + "log.debug", + "New organisation entity created", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "active": true, + "name": "Entity new-entity" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente I", + "organisation": "new-entity", + "@self": { + "owner": "new-entity", + "organisation": "new-entity" + } + } + ], + [ + "entity.setOwner", + "o-1", + "new-entity" + ], + [ + "entity.setOrganisation", + "o-1", + "new-entity" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente I", + "organisation": "new-entity", + "@self": { + "organisation": "new-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "ownerSet": "new-entity", + "organisationSet": "new-entity" + } + ] + ] + }, + "entity: no name, created": { + "returned": [ + "new-entity", + true + ], + "stats": { + "entitiesCreated": 1, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Unknown", + "status": "actief" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Unknown" + } + ], + [ + "organisations.create", + { + "status": "actief" + } + ], + [ + "log.debug", + "New organisation entity created", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "active": true, + "name": "Entity new-entity" + } + ], + [ + "log.info", + "[FLOW] Sending organization registration email", + { + "organisatieId": "o-1" + } + ], + [ + "mail.sendOrganizationRegistrationEmail", + null + ], + [ + "log.info", + "📧 Organization registration email sent successfully", + { + "organisatieId": "o-1" + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "status": "actief", + "organisation": "new-entity", + "@self": { + "owner": "new-entity", + "organisation": "new-entity" + } + } + ], + [ + "entity.setOwner", + "o-1", + "new-entity" + ], + [ + "entity.setOrganisation", + "o-1", + "new-entity" + ], + [ + "objects.saveObject", + { + "object": { + "status": "actief", + "organisation": "new-entity", + "@self": { + "organisation": "new-entity" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.info", + "OrganizationSyncService: Successfully updated organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "new-entity", + "ownerSet": "new-entity", + "organisationSet": "new-entity" + } + ] + ] + }, + "entity: creation fails": { + "returned": null, + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente J", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "mapper.findBySlug", + "gemeente-j" + ], + [ + "log.debug", + "Creating new organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente J" + } + ], + [ + "organisations.create", + { + "name": "Gemeente J" + } + ], + [ + "log.error", + "❌ ORGANISATION ENTITY CREATION FAILED", + { + "app": "stackiq", + "organisatieId": "o-1" + } + ] + ] + }, + "entity: mapper fails": { + "returned": null, + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente K", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.error", + "💥 ENSURE ORGANISATION ENTITY EXCEPTION", + { + "app": "stackiq", + "organisatieId": 7, + "exception": "db down", + "file": "(file)", + "line": "(line)", + "trace": "(trace)" + } + ] + ] + }, + "entity: owner save fails": { + "returned": [ + "o-1", + true + ], + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente L", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "mapper.findByUuid", + "o-1" + ], + [ + "log.debug", + "Existing entity found", + { + "app": "stackiq", + "organisatieId": "o-1", + "entityId": 41, + "shouldBeActive": true, + "needsUpdate": false + } + ], + [ + "log.info", + "OrganizationSyncService: Updating organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "register": "reg-v", + "schema": "org-schema" + } + ], + [ + "entity.setObject", + "o-1", + { + "name": "Gemeente L", + "organisation": "o-1", + "@self": { + "owner": "o-1", + "organisation": "o-1" + } + } + ], + [ + "entity.setOwner", + "o-1", + "o-1" + ], + [ + "entity.setOrganisation", + "o-1", + "o-1" + ], + [ + "objects.saveObject", + { + "object": { + "name": "Gemeente L", + "organisation": "o-1", + "@self": { + "organisation": "o-1" + } + }, + "register": "reg-entity", + "schema": "schema-entity", + "uuid": "o-1", + "silent": true, + "_validation": false + } + ], + [ + "log.error", + "OrganizationSyncService: Failed to update organisatie object owner and organisation", + { + "organisatieId": "o-1", + "organisationEntityUuid": "o-1", + "exception": "save failed", + "file": "(file)", + "line": "(line)" + } + ] + ] + }, + "entity: settings fail": { + "returned": null, + "stats": { + "entitiesCreated": 0, + "entitiesUpdated": 0 + }, + "trace": [ + [ + "objects.find", + { + "id": "o-1", + "register": "reg-entity", + "schema": "schema-entity", + "_rbac": false, + "_multitenancy": false + } + ], + [ + "log.debug", + "Ensuring organisation entity", + { + "app": "stackiq", + "organisatieId": "o-1", + "name": "Gemeente M", + "status": "Unknown" + } + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.error", + "💥 ENSURE ORGANISATION ENTITY EXCEPTION", + { + "app": "stackiq", + "organisatieId": 7, + "exception": "settings down", + "file": "(file)", + "line": "(line)", + "trace": "(trace)" + } + ] + ] + } +} From 80859ef15ecef39162ea6b6f324ae97cb19bb6a1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 15:08:00 +0100 Subject: [PATCH 06/10] refactor(organization-sync): split ensuring the organisation entity into refresh, adopt-by-slug and create steps ensureOrganisationEntity takes the organisatie object and one run array (stats held by reference, and an explicit sendEmails at every call site) and hands the work to fullOrganisationObject, refreshOrganisationEntity, adoptOrganisationEntityBySlug, createOrganisationEntity, mailOrganisation and makeEntityOwner. The public wrapper keeps its signature. Lookups, saves, mails, log lines and stats are unchanged (golden of the previous commit). Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/OrganizationSyncService.php | 438 +++++++++++++----------- 1 file changed, 240 insertions(+), 198 deletions(-) diff --git a/lib/Service/OrganizationSyncService.php b/lib/Service/OrganizationSyncService.php index 20d41cc6..7cc7e4d0 100644 --- a/lib/Service/OrganizationSyncService.php +++ b/lib/Service/OrganizationSyncService.php @@ -56,6 +56,8 @@ * @SuppressWarnings(PHPMD.Superglobals) * @SuppressWarnings(PHPMD.CamelCaseVariableName) * @SuppressWarnings(PHPMD.CamelCaseParameterName) + * + * @spec openspec/specs/organization-sync/spec.md#requirement-the-system-shall-process-individual-organisationcontact-records-and-ensure-their-openregister-entity-req-002 */ class OrganizationSyncService { @@ -428,7 +430,7 @@ public function performOrganizationsSync(int $batchSize = 50, int $maxExecutionS _multitenancy: false ); - $this->ensureOrganisationEntity(organisationObject: $object, stats: $stats, sendEmails: false); + $this->ensureOrganisationEntity(organisationObject: $object, run: ['stats' => &$stats, 'sendEmails' => false]); $stats['organizationsProcessed']++; } catch (\Exception $e) { $this->handleSyncError(operation: 'OrganizationSync', identifier: (string)$row['uuid'], exception: $e, stats: $stats); @@ -892,7 +894,7 @@ private function processOrganisationObject( ); // Step 1: Ensure organisation entity exists. - $organisationEntity = $this->ensureOrganisationEntity(organisationObject: $organisationObject, stats: $stats); + $organisationEntity = $this->ensureOrganisationEntity(organisationObject: $organisationObject, run: ['stats' => &$stats, 'sendEmails' => true]); if ($organisationEntity === null) { $this->logger->warning( 'OrganizationSyncService: Could not ensure organisation entity', @@ -946,8 +948,7 @@ private function processOrganisationObject( public function ensureOrganisationEntityPublic(object $organisationObject, array &$stats, bool $sendEmails = true): ?object { return $this->ensureOrganisationEntity( organisationObject: $organisationObject, - stats: $stats, - sendEmails: $sendEmails + run: ['stats' => &$stats, 'sendEmails' => $sendEmails] ); }//end ensureOrganisationEntityPublic() @@ -956,40 +957,16 @@ public function ensureOrganisationEntityPublic(object $organisationObject, array * Ensures organisation entity exists for organisatie object. * * @param object $organisationObject The organisatie object. - * @param array $stats Statistics array (passed by reference). - * @param bool $sendEmails Whether to send emails. + * @param array $run The sync run: `stats` (held by reference) and `sendEmails`, + * whether to send registration and activation e-mails. * * @return object|null The organisation entity or null on failure. - * - * @SuppressWarnings(PHPMD.BooleanArgumentFlag) $sendEmails is a simple notification toggle */ - private function ensureOrganisationEntity(object $organisationObject, array &$stats, bool $sendEmails = true): ?object { + private function ensureOrganisationEntity(object $organisationObject, array $run): ?object { try { // Get the full object data - the passed object might not have all fields populated. $organisationId = $organisationObject->getUuid(); - - // Fetch the complete object from the database to ensure we have all data. - try { - $fullObject = $this->objectService->find( - id: $organisationId, - register: $organisationObject->getRegister(), - schema: $organisationObject->getSchema(), - _rbac: false, - _multitenancy: false - ); - if (empty($fullObject) === false) { - $organisationObject = $fullObject; - } - } catch (\Exception $e) { - $this->logger->warning( - 'Could not fetch full organisation object, using provided object', - [ - 'organisatieId' => $organisationId, - 'error' => $e->getMessage(), - ] - ); - }//end try - + $organisationObject = $this->fullOrganisationObject(organisationObject: $organisationObject); $objectData = $organisationObject->getObject(); $this->logger->debug( @@ -1004,197 +981,262 @@ private function ensureOrganisationEntity(object $organisationObject, array &$st // Get configuration for object updates. $voorzieningenConfig = $this->settingsService->getVoorzieningenConfig(); - $register = ($voorzieningenConfig['register'] ?? ''); - $organizationSchema = ($voorzieningenConfig['organisatie_schema'] ?? ''); + $run['organisationId'] = $organisationId; + $run['organisationObject'] = $organisationObject; + $run['objectData'] = $objectData; + $run['register'] = ($voorzieningenConfig['register'] ?? ''); + $run['organizationSchema'] = ($voorzieningenConfig['organisatie_schema'] ?? ''); // Try to find existing organisation entity. - try { $organisationEntity = $this->organisationMapper->findByUuid($organisationId); + return $this->refreshOrganisationEntity(organisationEntity: $organisationEntity, run: $run); + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + return $this->adoptOrganisationEntityBySlug(run: $run) ?? $this->createOrganisationEntity(run: $run); + } + } catch (\Exception $e) { + $this->logger->error( + '💥 ENSURE ORGANISATION ENTITY EXCEPTION', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationObject->getId(), + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + ] + ); + return null; + }//end try - // Entity exists - update it if needed. - $status = strtolower(($objectData['status'] ?? 'actief')); - $shouldBeActive = in_array($status, ['actief', 'active']) === true; + }//end ensureOrganisationEntity() - $this->logger->debug( - 'Existing entity found', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'entityId' => $organisationEntity->getId(), - 'shouldBeActive' => $shouldBeActive, - 'needsUpdate' => $organisationEntity->getActive() !== $shouldBeActive, - ] - ); + /** + * The organisatie object as stored, or the one given when it cannot be fetched. + * + * @param object $organisationObject The organisatie object as passed in. + * + * @return object The complete organisatie object + */ + private function fullOrganisationObject(object $organisationObject): object { + $organisationId = $organisationObject->getUuid(); + try { + $fullObject = $this->objectService->find( + id: $organisationId, + register: $organisationObject->getRegister(), + schema: $organisationObject->getSchema(), + _rbac: false, + _multitenancy: false + ); + if (empty($fullObject) === false) { + return $fullObject; + } + } catch (\Exception $e) { + $this->logger->warning( + 'Could not fetch full organisation object, using provided object', + [ + 'organisatieId' => $organisationId, + 'error' => $e->getMessage(), + ] + ); + }//end try - if ($organisationEntity->getActive() !== $shouldBeActive) { - $this->logger->debug( - 'Updating entity status', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'oldActive' => $organisationEntity->getActive(), - 'newActive' => $shouldBeActive, - ] - ); + return $organisationObject; + }//end fullOrganisationObject() - $wasActive = $organisationEntity->getActive(); - $organisationEntity->setActive($shouldBeActive); - $this->organisationMapper->save($organisationEntity); - $stats['entitiesUpdated']++; + /** + * Bring an existing organisation entity's active flag in line with the object's status, + * mail an organisation that became active, and make the entity the object's owner. + * + * @param object $organisationEntity The existing organisation entity. + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return object The organisation entity + */ + private function refreshOrganisationEntity(object $organisationEntity, array $run): object { + $organisationId = $run['organisationId']; - // Send activation email if organization became active. - if ($sendEmails !== false && $shouldBeActive === true && $wasActive === false) { - $this->logger->info( - '[FLOW] Sending organization activation email', - ['organisatieId' => $organisationId] - ); - $emailSent = $this->sendOrganizationActivationEmail(organizationData: $objectData); - if (empty($emailSent) === false) { - $this->logger->info( - '📧 Organization activation email sent successfully', - ['organisatieId' => $organisationId] - ); - } + // Entity exists - update it if needed. + $status = strtolower(($run['objectData']['status'] ?? 'actief')); + $shouldBeActive = in_array($status, ['actief', 'active']) === true; - if (empty($emailSent) === true) { - $this->logger->info( - '📧 Organization activation email not sent (disabled or not configured)', - ['organisatieId' => $organisationId] - ); - } - }//end if - }//end if + $this->logger->debug( + 'Existing entity found', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationId, + 'entityId' => $organisationEntity->getId(), + 'shouldBeActive' => $shouldBeActive, + 'needsUpdate' => $organisationEntity->getActive() !== $shouldBeActive, + ] + ); - // Update organisatie object owner to organisation entity UUID. - $this->updateOrganisationObjectOwner( - organisationObject: $organisationObject, - organisationEntity: $organisationEntity, - register: $register, - organizationSchema: $organizationSchema - ); + if ($organisationEntity->getActive() !== $shouldBeActive) { + $this->logger->debug( + 'Updating entity status', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationId, + 'oldActive' => $organisationEntity->getActive(), + 'newActive' => $shouldBeActive, + ] + ); - return $organisationEntity; - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - // Entity not found by UUID — try finding by slug before creating. - // This handles the case where the entity exists but with a different UUID (e.g. from slug collision). - $orgName = ($objectData['name'] ?? ''); - if (empty($orgName) === false) { - $slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', strtolower($orgName))); - $slug = trim($slug, '-'); - try { - $organisationEntity = $this->organisationMapper->findBySlug($slug); - $this->logger->info( - 'OrganizationSyncService: Found existing entity by slug, updating UUID to match object', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'oldEntityUuid' => $organisationEntity->getUuid(), - 'slug' => $slug, - ] - ); + $wasActive = $organisationEntity->getActive(); + $organisationEntity->setActive($shouldBeActive); + $this->organisationMapper->save($organisationEntity); + $run['stats']['entitiesUpdated']++; - // Update the entity's UUID to match the object UUID so future lookups work. - $organisationEntity->setUuid($organisationId); - $this->organisationMapper->save($organisationEntity); - $stats['entitiesUpdated']++; + // Send activation email if organization became active. + if ($run['sendEmails'] !== false && $shouldBeActive === true && $wasActive === false) { + $this->mailOrganisation(kind: 'activation', run: $run); + } + }//end if - // Update organisatie object owner to this entity. - $this->updateOrganisationObjectOwner( - organisationObject: $organisationObject, - organisationEntity: $organisationEntity, - register: $register, - organizationSchema: $organizationSchema - ); + // Update organisatie object owner to organisation entity UUID. + $this->makeEntityOwner(organisationEntity: $organisationEntity, run: $run); - return $organisationEntity; - } catch (\OCP\AppFramework\Db\DoesNotExistException $slugEx) { - // Not found by slug either — proceed with creation. - }//end try - }//end if + return $organisationEntity; + }//end refreshOrganisationEntity() - $this->logger->debug( - 'Creating new organisation entity', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'name' => ($objectData['name'] ?? 'Unknown'), - ] - ); + /** + * Adopt an entity that has the organisation's slug but another uuid: it takes the object's uuid. + * + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return object|null The adopted entity, or null when the object has no name or no entity has its slug + */ + private function adoptOrganisationEntityBySlug(array $run): ?object { + // Entity not found by UUID — try finding by slug before creating. + // This handles the case where the entity exists but with a different UUID (e.g. from slug collision). + $orgName = ($run['objectData']['name'] ?? ''); + if (empty($orgName) === true) { + return null; + } - $organisationEntity = $this->organisationService->createOrganisationInOpenRegister($objectData); - if (empty($organisationEntity) === false) { - $stats['entitiesCreated']++; - $this->logger->debug( - 'New organisation entity created', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - 'entityId' => $organisationEntity->getId(), - 'active' => $organisationEntity->getActive(), - 'name' => $organisationEntity->getName(), - ] - ); + $slug = strtolower(preg_replace('/[^a-z0-9]+/', '-', strtolower($orgName))); + $slug = trim($slug, '-'); + try { + $organisationEntity = $this->organisationMapper->findBySlug($slug); + $this->logger->info( + 'OrganizationSyncService: Found existing entity by slug, updating UUID to match object', + [ + 'app' => 'stackiq', + 'organisatieId' => $run['organisationId'], + 'oldEntityUuid' => $organisationEntity->getUuid(), + 'slug' => $slug, + ] + ); - // Send registration email for new organization (skip for backfill). - if (empty($sendEmails) === false) { - $this->logger->info( - '[FLOW] Sending organization registration email', - ['organisatieId' => $organisationId] - ); - $emailSent = $this->sendOrganizationRegistrationEmail(organizationData: $objectData); - if (empty($emailSent) === false) { - $this->logger->info( - '📧 Organization registration email sent successfully', - ['organisatieId' => $organisationId] - ); - } + // Update the entity's UUID to match the object UUID so future lookups work. + $organisationEntity->setUuid($run['organisationId']); + $this->organisationMapper->save($organisationEntity); + $run['stats']['entitiesUpdated']++; - if (empty($emailSent) === true) { - $this->logger->info( - '📧 Organization registration email not sent (disabled or not configured)', - ['organisatieId' => $organisationId] - ); - } - }//end if + // Update organisatie object owner to this entity. + $this->makeEntityOwner(organisationEntity: $organisationEntity, run: $run); - // Update organisatie object owner to organisation entity UUID. - $this->updateOrganisationObjectOwner( - organisationObject: $organisationObject, - organisationEntity: $organisationEntity, - register: $register, - organizationSchema: $organizationSchema - ); - }//end if + return $organisationEntity; + } catch (\OCP\AppFramework\Db\DoesNotExistException $slugEx) { + // Not found by slug either — proceed with creation. + return null; + }//end try + }//end adoptOrganisationEntityBySlug() - if (empty($organisationEntity) === true) { - $this->logger->error( - '❌ ORGANISATION ENTITY CREATION FAILED', - [ - 'app' => 'stackiq', - 'organisatieId' => $organisationId, - ] - ); - } + /** + * Create the organisation entity, mail the new organisation and make the entity the object's owner. + * + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return object|null The new entity, or what creation returned when it failed + */ + private function createOrganisationEntity(array $run): ?object { + $organisationId = $run['organisationId']; + $this->logger->debug( + 'Creating new organisation entity', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationId, + 'name' => ($run['objectData']['name'] ?? 'Unknown'), + ] + ); - return $organisationEntity; - }//end try - } catch (\Exception $e) { + $organisationEntity = $this->organisationService->createOrganisationInOpenRegister($run['objectData']); + if (empty($organisationEntity) === true) { $this->logger->error( - '💥 ENSURE ORGANISATION ENTITY EXCEPTION', + '❌ ORGANISATION ENTITY CREATION FAILED', [ 'app' => 'stackiq', - 'organisatieId' => $organisationObject->getId(), - 'exception' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => $e->getTraceAsString(), + 'organisatieId' => $organisationId, ] ); - return null; - }//end try + return $organisationEntity; + } - }//end ensureOrganisationEntity() + $run['stats']['entitiesCreated']++; + $this->logger->debug( + 'New organisation entity created', + [ + 'app' => 'stackiq', + 'organisatieId' => $organisationId, + 'entityId' => $organisationEntity->getId(), + 'active' => $organisationEntity->getActive(), + 'name' => $organisationEntity->getName(), + ] + ); + + // Send registration email for new organization (skip for backfill). + if (empty($run['sendEmails']) === false) { + $this->mailOrganisation(kind: 'registration', run: $run); + } + + // Update organisatie object owner to organisation entity UUID. + $this->makeEntityOwner(organisationEntity: $organisationEntity, run: $run); + + return $organisationEntity; + }//end createOrganisationEntity() + + /** + * Send an organisation its registration or activation e-mail and log whether it went out. + * + * @param string $kind 'registration' or 'activation'. + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return void + */ + private function mailOrganisation(string $kind, array $run): void { + $context = ['organisatieId' => $run['organisationId']]; + $this->logger->info('[FLOW] Sending organization ' . $kind . ' email', $context); + if ($kind === 'activation') { + $emailSent = $this->sendOrganizationActivationEmail(organizationData: $run['objectData']); + } else { + $emailSent = $this->sendOrganizationRegistrationEmail(organizationData: $run['objectData']); + } + + if (empty($emailSent) === false) { + $this->logger->info('📧 Organization ' . $kind . ' email sent successfully', $context); + return; + } + + $this->logger->info('📧 Organization ' . $kind . ' email not sent (disabled or not configured)', $context); + }//end mailOrganisation() + + /** + * Make the organisation entity the owner and organisation of the run's organisatie object. + * + * @param object $organisationEntity The organisation entity. + * @param array $run The ensure run (see ensureOrganisationEntity). + * + * @return void + */ + private function makeEntityOwner(object $organisationEntity, array $run): void { + $this->updateOrganisationObjectOwner( + organisationObject: $run['organisationObject'], + organisationEntity: $organisationEntity, + register: $run['register'], + organizationSchema: $run['organizationSchema'] + ); + }//end makeEntityOwner() /** * Safely sends organization registration email with error handling @@ -1724,7 +1766,7 @@ public function processSpecificOrganization($organizationObject): array { ] ); - $organisationEntity = $this->ensureOrganisationEntity(organisationObject: $organizationObject, stats: $stats); + $organisationEntity = $this->ensureOrganisationEntity(organisationObject: $organizationObject, run: ['stats' => &$stats, 'sendEmails' => true]); if (empty($organisationEntity) === false) { $stats['organizationsProcessed']++; @@ -2611,7 +2653,7 @@ private function createBackupOrganisationEntity(string $organizationUuid): ?obje ]; return $this->ensureOrganisationEntity( organisationObject: $orgObject, - stats: $backupStats + run: ['stats' => &$backupStats, 'sendEmails' => true] ); } } catch (\Exception $backupEx) { @@ -2902,7 +2944,7 @@ public function processSpecificContactPerson($contactObject): array { ]; $organisationEntity = $this->ensureOrganisationEntity( organisationObject: $orgObject, - stats: $backupStats + run: ['stats' => &$backupStats, 'sendEmails' => true] ); } } catch (\Exception $backupEx) { From 01b0c420e29583481143db0eb01bd9dac942ff2f Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 15:14:20 +0100 Subject: [PATCH 07/10] test(stackiq): golden for the organisation sync with OpenRegister, as it behaves today Creating an organisation escapes with an Error on both paths: the signed-in path calls OrganisationMapper::createWithUuid, which OpenRegister does not have, and the anonymous path saves and then reads the absent user. Assisted-by: ClaudeCode:claude-opus-5-5 --- ...ackiqServiceOrganisationSyncGoldenTest.php | 393 ++++++ .../stackiqServiceOrganisationSync.json | 1243 +++++++++++++++++ 2 files changed, 1636 insertions(+) create mode 100644 tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php create mode 100644 tests/Unit/Service/golden/stackiqServiceOrganisationSync.json diff --git a/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php b/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php new file mode 100644 index 00000000..1f49ea4d --- /dev/null +++ b/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php @@ -0,0 +1,393 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/softwarecatalogue-orchestration/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\OpenRegister\Contract\ObjectServiceInterface; +use OCA\OpenRegister\Db\Organisation; +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\OpenRegister\Service\OrganisationService; +use OCA\Stackiq\Service\SettingsService; +use OCA\Stackiq\Service\Stackiq\ContactPersonHandler; +use OCA\Stackiq\Service\Stackiq\GroupHandler; +use OCA\Stackiq\Service\Stackiq\HierarchyHandler; +use OCA\Stackiq\Service\Stackiq\OrganizationHandler; +use OCA\Stackiq\Service\StackiqService; +use OCA\Stackiq\Service\SymfonyEmailService; +use OCP\App\IAppManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use OCP\IUserSession; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\AbstractLogger; + +/** + * Golden cases for the organisation sync with OpenRegister. + * + * @covers \OCA\Stackiq\Service\StackiqService + * + * @spec openspec/specs/softwarecatalogue-orchestration/spec.md + */ +class StackiqServiceOrganisationSyncGoldenTest extends TestCase { + + /** + * Everything the doubles saw, in order. + * + * @var array + */ + private array $trace = []; + + /** + * Per-case behaviour of the doubles. + * + * @var array + */ + private array $plan = []; + + /** + * The golden file. + * + * @return string + */ + private static function golden(): string { + return __DIR__ . '/golden/stackiqServiceOrganisationSync.json'; + + }//end golden() + + /** + * The cases: entry point, organisation data and the doubles' behaviour. + * + * @return array}> + */ + public static function cases(): array { + $org = [ + 'id' => 'org-1', + 'name' => 'Gemeente A', + 'website' => 'https://a.nl', + 'beoordeling' => 'actief', + 'contactpersonen' => [['email' => 'a@a.nl'], ['naam' => 'zonder mail'], 'c-2'], + ]; + return [ + 'sync, OpenRegister disabled' => ['sync', $org, ['disabled' => true]], + 'sync, existing organisation' => ['sync', $org, ['exists' => true]], + 'sync, existing, save fails' => ['sync', $org, ['exists' => true, 'saveThrows' => true]], + 'sync, new, signed in' => ['sync', $org, []], + 'sync, new, signed in with schema' => ['sync', $org, ['schema' => true, 'contacts' => [['email' => 'b@a.nl', 'organization' => 'org-1'], ['email' => 'c@a.nl', 'organization' => 'org-9']]]], + 'sync, new, anonymous' => ['sync', $org, ['anonymous' => true]], + 'create, no id' => ['create', ['name' => 'X'], []], + 'create, signed in' => ['create', $org, []], + 'create, organisation service down' => ['create', $org, ['serviceThrows' => true]], + 'create, anonymous, no mapper' => ['create', $org, ['anonymous' => true, 'noMapper' => true]], + ]; + + }//end cases() + + /** + * Each case matches its recording. + * + * @param string $entry Which entry point runs. + * @param array $data The organisation object data. + * @param array $plan The behaviour of the doubles. + * + * @return void + * + * @dataProvider cases + */ + public function testSyncMatchesTheRecording(string $entry, array $data, array $plan): void { + $this->plan = $plan; + $this->trace = []; + $service = $this->service(); + try { + if ($entry === 'sync') { + $result = $service->syncOrganizationWithOpenRegister($this->stored(data: $data, id: 17)); + } else { + $result = $service->createOrganisationInOpenRegister($data); + if (is_object($result) === true) { + $result = 'entity:' . $result->getUuid(); + } + } + } catch (\Throwable $e) { + $result = ['escaped' => get_class($e), 'message' => preg_replace('/Mock\w+/', 'Mock', $e->getMessage())]; + } + + $this->check(key: $entry . ': ' . $this->dataName(), value: ['result' => $result, 'calls' => $this->trace]); + + }//end testSyncMatchesTheRecording() + + /** + * The service with recording doubles. + * + * @return StackiqService + */ + private function service(): StackiqService { + $appManager = $this->createMock(IAppManager::class); + $appManager->method('isEnabledForUser')->willReturn(isset($this->plan['disabled']) === false); + + $mapper = $this->mapper(); + $objects = $this->objectService(); + $settings = $this->createMock(SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturnCallback( + function (): array { + $this->trace[] = ['settings.getVoorzieningenConfig']; + return isset($this->plan['schema']) === true ? ['contactpersoon_schema' => '9'] : []; + } + ); + $organisations = $this->createMock(OrganisationService::class); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + function (string $id) use ($mapper, $objects, $settings, $organisations) { + $this->trace[] = ['container.get', $id]; + if ($id === 'OCA\OpenRegister\Service\OrganisationService' && isset($this->plan['serviceThrows']) === true) { + throw new \RuntimeException('organisation service down'); + } + + if ($id === 'OCA\OpenRegister\Db\OrganisationMapper' && isset($this->plan['noMapper']) === true) { + throw new \RuntimeException('no mapper'); + } + + $answers = [ + 'OCA\OpenRegister\Db\OrganisationMapper' => $mapper, + 'OCA\OpenRegister\Service\ObjectService' => $objects, + 'OCA\Stackiq\Service\SettingsService' => $settings, + 'OCA\OpenRegister\Service\OrganisationService' => $organisations, + ]; + return $answers[$id] ?? null; + } + ); + + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('signed-in'); + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn(isset($this->plan['anonymous']) === true ? null : $user); + + return new StackiqService( + _organizationHandler: $this->createMock(OrganizationHandler::class), + _contactPersonHandler: $this->createMock(ContactPersonHandler::class), + _groupHandler: $this->createMock(GroupHandler::class), + _hierarchyHandler: $this->createMock(HierarchyHandler::class), + _emailService: $this->createMock(SymfonyEmailService::class), + _logger: $this->logger(), + _container: $container, + _appManager: $appManager, + _userSession: $userSession, + _userManager: $this->createMock(IUserManager::class), + _groupManager: $this->createMock(IGroupManager::class) + ); + + }//end service() + + /** + * An organisation mapper that records lookups and saves. + * + * @return OrganisationMapper + */ + private function mapper(): OrganisationMapper { + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findByUuid')->willReturnCallback( + function (string $uuid) { + $this->trace[] = ['mapper.findByUuid', $uuid]; + if (isset($this->plan['exists']) === false) { + throw new DoesNotExistException('missing'); + } + + return $this->organisation(uuid: $uuid); + } + ); + $mapper->method('save')->willReturnCallback( + function ($organisation) { + $this->trace[] = ['mapper.save', $organisation->getUuid()]; + if (isset($this->plan['saveThrows']) === true) { + throw new \RuntimeException('save failed'); + } + + return $organisation; + } + ); + + return $mapper; + + }//end mapper() + + /** + * An organisation entity double that records its writes. + * + * @param string $uuid The uuid. + * + * @return Organisation + */ + private function organisation(string $uuid): Organisation { + $trace = &$this->trace; + $organisation = new class($trace) extends Organisation { + /** + * @param array $trace The shared trace. + */ + public function __construct(private array &$trace) { + } + + /** + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + $this->trace[] = ['organisation.' . $name, $args]; + return null; + } + }; + $organisation->setUuid($uuid); + return $organisation; + + }//end organisation() + + /** + * An object service that answers the contact person lookups. + * + * @return ObjectServiceInterface + */ + private function objectService(): ObjectServiceInterface { + $objects = $this->createMock(ObjectServiceInterface::class); + $objects->method('findAll')->willReturnCallback( + function (array $config) { + $this->trace[] = ['objects.findAll', $config]; + $found = []; + foreach ($this->plan['contacts'] ?? [] as $i => $data) { + $found[] = $this->stored(data: $data, id: 100 + $i); + } + + return $found; + } + ); + + return $objects; + + }//end objectService() + + /** + * A stored object double with an id and its data. + * + * @param array $data The object data. + * @param int $id The object id. + * + * @return object + */ + private function stored(array $data, int $id): object { + return new class($data, $id) { + /** + * @param array $data The object data. + * @param int $id The object id. + */ + public function __construct(private array $data, private int $id) { + } + + /** + * @return int + */ + public function getId(): int { + return $this->id; + } + + /** + * @return array + */ + public function getObject(): array { + return $this->data; + } + }; + + }//end stored() + + /** + * A logger that records every line, without stack traces. + * + * @return AbstractLogger + */ + private function logger(): AbstractLogger { + $trace = &$this->trace; + return new class($trace) extends AbstractLogger { + /** + * @param array $trace The shared trace. + */ + public function __construct(private array &$trace) { + } + + /** + * @param mixed $level The level. + * @param string|\Stringable $message The message. + * @param array $context The context. + * + * @return void + */ + public function log($level, string|\Stringable $message, array $context = []): void { + foreach (['trace', 'file', 'line'] as $volatile) { + if (array_key_exists($volatile, $context) === true) { + $context[$volatile] = '(' . $volatile . ')'; + } + } + + array_walk_recursive( + $context, + function (&$value) { + if (is_string($value) === true) { + $value = preg_replace('/Mock\w+/', 'Mock', $value); + } + } + ); + $this->trace[] = ['log.' . $level, preg_replace('/Mock\w+/', 'Mock', (string) $message), $context]; + } + }; + + }//end logger() + + /** + * Compare a case with the golden file, or record it. + * + * @param string $key The case. + * @param array $value What the case observed. + * + * @return void + */ + private function check(string $key, array $value): void { + $json = json_decode(json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR), true); + $file = self::golden(); + $saved = []; + if (file_exists($file) === true) { + $saved = json_decode((string) file_get_contents($file), true); + } + + if (getenv('RECORD_GOLDEN') !== false) { + $saved[$key] = $json; + file_put_contents($file, json_encode($saved, (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . "\n"); + $this->addToAssertionCount(count: 1); + return; + } + + $this->assertArrayHasKey(key: $key, array: $saved, message: 'No golden for ' . $key); + $this->assertSame(expected: $saved[$key], actual: $json); + + }//end check() + +}//end class diff --git a/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json b/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json new file mode 100644 index 00000000..9d2ec879 --- /dev/null +++ b/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json @@ -0,0 +1,1243 @@ +{ + "sync: sync, OpenRegister disabled": { + "result": false, + "calls": [ + [ + "log.info", + "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", + { + "objectId": 17, + "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_2 - Extracted organization data", + { + "organizationUuid": "org-1", + "objectDataKeys": [ + "id", + "name", + "website", + "beoordeling", + "contactpersonen" + ], + "hasId": true + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - Getting OrganisationService", + [] + ], + [ + "log.error", + "StackiqService: SYNC_STEP_3 - OpenRegister OrganisationService not available", + [] + ] + ] + }, + "sync: sync, existing organisation": { + "result": true, + "calls": [ + [ + "log.info", + "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", + { + "objectId": 17, + "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_2 - Extracted organization data", + { + "organizationUuid": "org-1", + "objectDataKeys": [ + "id", + "name", + "website", + "beoordeling", + "contactpersonen" + ], + "hasId": true + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - Getting OrganisationService", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", + { + "serviceClass": "Mock" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_4 - OpenRegister configuration", + { + "organizationUuid": "org-1", + "organizationName": "Gemeente A" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", + [] + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5B - Calling findByUuid", + { + "uuid": "org-1" + } + ], + [ + "mapper.findByUuid", + "org-1" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_6 - Organization exists in OpenRegister, updating", + { + "organizationId": "org-1", + "existingOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:243$c0" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_7 - Mapping organization data", + [] + ], + [ + "log.info", + "StackiqService: Updating organization in OpenRegister", + { + "organizationUuid": "org-1", + "name": "Gemeente A" + } + ], + [ + "organisation.setName", + [ + "Gemeente A" + ] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "mapper.save", + "org-1" + ], + [ + "log.info", + "StackiqService: Successfully updated organization in OpenRegister", + { + "organizationUuid": "org-1", + "openRegisterId": "org-1" + } + ], + [ + "log.info", + "StackiqService: Successfully updated organization in OpenRegister", + { + "organizationId": "org-1", + "openRegisterId": "org-1" + } + ] + ] + }, + "sync: sync, existing, save fails": { + "result": false, + "calls": [ + [ + "log.info", + "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", + { + "objectId": 17, + "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_2 - Extracted organization data", + { + "organizationUuid": "org-1", + "objectDataKeys": [ + "id", + "name", + "website", + "beoordeling", + "contactpersonen" + ], + "hasId": true + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - Getting OrganisationService", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", + { + "serviceClass": "Mock" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_4 - OpenRegister configuration", + { + "organizationUuid": "org-1", + "organizationName": "Gemeente A" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", + [] + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5B - Calling findByUuid", + { + "uuid": "org-1" + } + ], + [ + "mapper.findByUuid", + "org-1" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_6 - Organization exists in OpenRegister, updating", + { + "organizationId": "org-1", + "existingOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:243$c0" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_7 - Mapping organization data", + [] + ], + [ + "log.info", + "StackiqService: Updating organization in OpenRegister", + { + "organizationUuid": "org-1", + "name": "Gemeente A" + } + ], + [ + "organisation.setName", + [ + "Gemeente A" + ] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "mapper.save", + "org-1" + ], + [ + "log.error", + "StackiqService: Failed to sync organization with OpenRegister: save failed", + { + "objectId": 17, + "exception": "save failed", + "file": "(file)", + "line": "(line)", + "trace": "(trace)" + } + ] + ] + }, + "sync: sync, new, signed in": { + "result": { + "escaped": "Error", + "message": "Call to undefined method Mock::createWithUuid()" + }, + "calls": [ + [ + "log.info", + "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", + { + "objectId": 17, + "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_2 - Extracted organization data", + { + "organizationUuid": "org-1", + "objectDataKeys": [ + "id", + "name", + "website", + "beoordeling", + "contactpersonen" + ], + "hasId": true + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - Getting OrganisationService", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", + { + "serviceClass": "Mock" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_4 - OpenRegister configuration", + { + "organizationUuid": "org-1", + "organizationName": "Gemeente A" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", + [] + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5B - Calling findByUuid", + { + "uuid": "org-1" + } + ], + [ + "mapper.findByUuid", + "org-1" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_8 - Organization not found in OpenRegister, creating", + { + "organizationId": "org-1", + "exception": "missing" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_9 - Mapping organization data for creation", + [] + ], + [ + "log.info", + "StackiqService: SYNC_STEP_10 - Calling createOrganisationInOpenRegister", + [] + ], + [ + "log.info", + "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", + { + "organizationUuid": "org-1", + "name": "Gemeente A", + "mappedDataKeys": [ + "name", + "type", + "website", + "active", + "contactpersonen", + "participants" + ] + } + ], + [ + "log.info", + "StackiqService: STEP 2 - Checking user context", + { + "hasUserSession": true, + "currentUser": "signed-in", + "isAnonymous": false + } + ], + [ + "log.info", + "StackiqService: STEP 4A - Auth path: User logged in, creating org via mapper", + { + "organizationUuid": "org-1", + "currentUser": "signed-in" + } + ], + [ + "log.info", + "StackiqService: STEP 4B - Using original UUID format for OpenRegister", + { + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "StackiqService: STEP 4C - Getting OrganisationMapper from container", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.info", + "StackiqService: STEP 4D - OrganisationMapper retrieved", + { + "mapperClass": "Mock" + } + ], + [ + "log.info", + "StackiqService: Processing nested contact persons", + { + "organizationUuid": "org-1", + "nestedContactPersonCount": 0 + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "container.get", + "OCA\\Stackiq\\Service\\SettingsService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.warning", + "StackiqService: Missing contactpersoon schema config for username extraction", + [] + ], + [ + "log.info", + "StackiqService: STEP 4E - Debug - UUID before createWithUuid", + { + "organizationUuid": "org-1", + "uuidLength": 5, + "uuidIsEmpty": false, + "name": "Gemeente A", + "description": "https://a.nl", + "owner": "signed-in", + "users": [ + "signed-in" + ], + "contactPersonUsernames": [] + } + ], + [ + "log.info", + "StackiqService: STEP 4F - Calling organisationMapper->createWithUuid()", + [] + ], + [ + "log.info", + "StackiqService: STEP 4F_DEBUG - Parameters for createWithUuid", + { + "name": "Gemeente A", + "description": "https://a.nl", + "uuid": "org-1", + "owner": "signed-in", + "users": [ + "signed-in" + ], + "isDefault": false, + "uuidLength": 5, + "uuidIsEmpty": false + } + ] + ] + }, + "sync: sync, new, signed in with schema": { + "result": { + "escaped": "Error", + "message": "Call to undefined method Mock::createWithUuid()" + }, + "calls": [ + [ + "log.info", + "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", + { + "objectId": 17, + "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_2 - Extracted organization data", + { + "organizationUuid": "org-1", + "objectDataKeys": [ + "id", + "name", + "website", + "beoordeling", + "contactpersonen" + ], + "hasId": true + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - Getting OrganisationService", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", + { + "serviceClass": "Mock" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_4 - OpenRegister configuration", + { + "organizationUuid": "org-1", + "organizationName": "Gemeente A" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", + [] + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5B - Calling findByUuid", + { + "uuid": "org-1" + } + ], + [ + "mapper.findByUuid", + "org-1" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_8 - Organization not found in OpenRegister, creating", + { + "organizationId": "org-1", + "exception": "missing" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_9 - Mapping organization data for creation", + [] + ], + [ + "log.info", + "StackiqService: SYNC_STEP_10 - Calling createOrganisationInOpenRegister", + [] + ], + [ + "log.info", + "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", + { + "organizationUuid": "org-1", + "name": "Gemeente A", + "mappedDataKeys": [ + "name", + "type", + "website", + "active", + "contactpersonen", + "participants" + ] + } + ], + [ + "log.info", + "StackiqService: STEP 2 - Checking user context", + { + "hasUserSession": true, + "currentUser": "signed-in", + "isAnonymous": false + } + ], + [ + "log.info", + "StackiqService: STEP 4A - Auth path: User logged in, creating org via mapper", + { + "organizationUuid": "org-1", + "currentUser": "signed-in" + } + ], + [ + "log.info", + "StackiqService: STEP 4B - Using original UUID format for OpenRegister", + { + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "StackiqService: STEP 4C - Getting OrganisationMapper from container", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.info", + "StackiqService: STEP 4D - OrganisationMapper retrieved", + { + "mapperClass": "Mock" + } + ], + [ + "log.info", + "StackiqService: Processing nested contact persons", + { + "organizationUuid": "org-1", + "nestedContactPersonCount": 0 + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "container.get", + "OCA\\Stackiq\\Service\\SettingsService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "objects.findAll", + { + "filters": { + "register": "6", + "schema": "9", + "organization": "org-1" + } + } + ], + [ + "log.info", + "StackiqService: Found existing contact persons for organization", + { + "organizationUuid": "org-1", + "contactPersonCount": 2, + "contactPersonIds": [ + 100, + 101 + ] + } + ], + [ + "log.info", + "StackiqService: Added existing contact person username", + { + "username": "b@a.nl", + "contactPersonId": 100 + } + ], + [ + "log.info", + "StackiqService: Added existing contact person username", + { + "username": "c@a.nl", + "contactPersonId": 101 + } + ], + [ + "log.info", + "StackiqService: Collected contact person usernames", + { + "organizationUuid": "org-1", + "totalUsernames": 2, + "usernames": [ + "b@a.nl", + "c@a.nl" + ] + } + ], + [ + "log.info", + "StackiqService: STEP 4E - Debug - UUID before createWithUuid", + { + "organizationUuid": "org-1", + "uuidLength": 5, + "uuidIsEmpty": false, + "name": "Gemeente A", + "description": "https://a.nl", + "owner": "signed-in", + "users": [ + "signed-in", + "b@a.nl", + "c@a.nl" + ], + "contactPersonUsernames": [ + "b@a.nl", + "c@a.nl" + ] + } + ], + [ + "log.info", + "StackiqService: STEP 4F - Calling organisationMapper->createWithUuid()", + [] + ], + [ + "log.info", + "StackiqService: STEP 4F_DEBUG - Parameters for createWithUuid", + { + "name": "Gemeente A", + "description": "https://a.nl", + "uuid": "org-1", + "owner": "signed-in", + "users": [ + "signed-in", + "b@a.nl", + "c@a.nl" + ], + "isDefault": false, + "uuidLength": 5, + "uuidIsEmpty": false + } + ] + ] + }, + "sync: sync, new, anonymous": { + "result": { + "escaped": "Error", + "message": "Call to undefined method OCA\\OpenRegister\\Db\\Organisation::setName()" + }, + "calls": [ + [ + "log.info", + "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", + { + "objectId": 17, + "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_2 - Extracted organization data", + { + "organizationUuid": "org-1", + "objectDataKeys": [ + "id", + "name", + "website", + "beoordeling", + "contactpersonen" + ], + "hasId": true + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - Getting OrganisationService", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", + { + "serviceClass": "Mock" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_4 - OpenRegister configuration", + { + "organizationUuid": "org-1", + "organizationName": "Gemeente A" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", + [] + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_5B - Calling findByUuid", + { + "uuid": "org-1" + } + ], + [ + "mapper.findByUuid", + "org-1" + ], + [ + "log.info", + "StackiqService: SYNC_STEP_8 - Organization not found in OpenRegister, creating", + { + "organizationId": "org-1", + "exception": "missing" + } + ], + [ + "log.info", + "StackiqService: SYNC_STEP_9 - Mapping organization data for creation", + [] + ], + [ + "log.info", + "StackiqService: SYNC_STEP_10 - Calling createOrganisationInOpenRegister", + [] + ], + [ + "log.info", + "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", + { + "organizationUuid": "org-1", + "name": "Gemeente A", + "mappedDataKeys": [ + "name", + "type", + "website", + "active", + "contactpersonen", + "participants" + ] + } + ], + [ + "log.info", + "StackiqService: STEP 2 - Checking user context", + { + "hasUserSession": true, + "currentUser": "null", + "isAnonymous": true + } + ], + [ + "log.info", + "StackiqService: STEP 3A - Anonymous path: No user, creating org directly via mapper", + { + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "StackiqService: STEP 3B - Using original UUID format for OpenRegister (anonymous)", + { + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "StackiqService: STEP 3C - Getting OrganisationMapper from container", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.info", + "StackiqService: STEP 3D - OrganisationMapper retrieved", + { + "mapperClass": "Mock" + } + ], + [ + "log.info", + "StackiqService: STEP 3E - Creating new Organisation entity", + [] + ], + [ + "log.info", + "StackiqService: STEP 3F - Setting organisation properties", + { + "name": "Gemeente A", + "description": "https://a.nl", + "uuid": "org-1" + } + ], + [ + "log.info", + "StackiqService: Processing nested contact persons", + { + "organizationUuid": "org-1", + "nestedContactPersonCount": 0 + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "container.get", + "OCA\\Stackiq\\Service\\SettingsService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.warning", + "StackiqService: Missing contactpersoon schema config for username extraction", + [] + ], + [ + "log.info", + "StackiqService: STEP 3F_2 - Collected usernames for organization", + { + "organizationUuid": "org-1", + "totalUsernames": 1, + "usernames": [ + "admin" + ] + } + ] + ] + }, + "create: create, no id": { + "result": null, + "calls": [ + [ + "log.error", + "StackiqService: No organization UUID provided for creation", + [] + ] + ] + }, + "create: create, signed in": { + "result": { + "escaped": "Error", + "message": "Call to undefined method Mock::createWithUuid()" + }, + "calls": [ + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.info", + "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", + { + "organizationUuid": "org-1", + "name": "Gemeente A", + "mappedDataKeys": [ + "name", + "type", + "website", + "active", + "contactpersonen", + "participants" + ] + } + ], + [ + "log.info", + "StackiqService: STEP 2 - Checking user context", + { + "hasUserSession": true, + "currentUser": "signed-in", + "isAnonymous": false + } + ], + [ + "log.info", + "StackiqService: STEP 4A - Auth path: User logged in, creating org via mapper", + { + "organizationUuid": "org-1", + "currentUser": "signed-in" + } + ], + [ + "log.info", + "StackiqService: STEP 4B - Using original UUID format for OpenRegister", + { + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "StackiqService: STEP 4C - Getting OrganisationMapper from container", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.info", + "StackiqService: STEP 4D - OrganisationMapper retrieved", + { + "mapperClass": "Mock" + } + ], + [ + "log.info", + "StackiqService: Processing nested contact persons", + { + "organizationUuid": "org-1", + "nestedContactPersonCount": 3 + } + ], + [ + "log.info", + "StackiqService: Added nested contact person username", + { + "username": "a@a.nl", + "contactPersonData": { + "email": "a@a.nl" + } + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "container.get", + "OCA\\Stackiq\\Service\\SettingsService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.warning", + "StackiqService: Missing contactpersoon schema config for username extraction", + [] + ], + [ + "log.info", + "StackiqService: STEP 4E - Debug - UUID before createWithUuid", + { + "organizationUuid": "org-1", + "uuidLength": 5, + "uuidIsEmpty": false, + "name": "Gemeente A", + "description": "https://a.nl", + "owner": "signed-in", + "users": [ + "signed-in", + "a@a.nl" + ], + "contactPersonUsernames": [ + "a@a.nl" + ] + } + ], + [ + "log.info", + "StackiqService: STEP 4F - Calling organisationMapper->createWithUuid()", + [] + ], + [ + "log.info", + "StackiqService: STEP 4F_DEBUG - Parameters for createWithUuid", + { + "name": "Gemeente A", + "description": "https://a.nl", + "uuid": "org-1", + "owner": "signed-in", + "users": [ + "signed-in", + "a@a.nl" + ], + "isDefault": false, + "uuidLength": 5, + "uuidIsEmpty": false + } + ] + ] + }, + "create: create, organisation service down": { + "result": null, + "calls": [ + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.error", + "StackiqService: Error in public createOrganisationInOpenRegister", + { + "error": "organisation service down", + "objectData": { + "id": "org-1", + "name": "Gemeente A", + "website": "https://a.nl", + "beoordeling": "actief", + "contactpersonen": [ + { + "email": "a@a.nl" + }, + { + "naam": "zonder mail" + }, + "c-2" + ] + } + } + ] + ] + }, + "create: create, anonymous, no mapper": { + "result": null, + "calls": [ + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "log.info", + "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", + { + "organizationUuid": "org-1", + "name": "Gemeente A", + "mappedDataKeys": [ + "name", + "type", + "website", + "active", + "contactpersonen", + "participants" + ] + } + ], + [ + "log.info", + "StackiqService: STEP 2 - Checking user context", + { + "hasUserSession": true, + "currentUser": "null", + "isAnonymous": true + } + ], + [ + "log.info", + "StackiqService: STEP 3A - Anonymous path: No user, creating org directly via mapper", + { + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "StackiqService: STEP 3B - Using original UUID format for OpenRegister (anonymous)", + { + "organizationUuid": "org-1" + } + ], + [ + "log.info", + "StackiqService: STEP 3C - Getting OrganisationMapper from container", + [] + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.error", + "Failed to get OrganisationMapper: no mapper", + [] + ], + [ + "log.error", + "StackiqService: Error in public createOrganisationInOpenRegister", + { + "error": "OpenRegister OrganisationMapper is not available", + "objectData": { + "id": "org-1", + "name": "Gemeente A", + "website": "https://a.nl", + "beoordeling": "actief", + "contactpersonen": [ + { + "email": "a@a.nl" + }, + { + "naam": "zonder mail" + }, + "c-2" + ] + } + } + ] + ] + } +} From e5ec6e3a81ca9ab15860769fc3ee7189acab16c5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 15:36:39 +0100 Subject: [PATCH 08/10] refactor(stackiq): drop the unused organisation service parameter from the OpenRegister create and update steps Neither createOrganisationInOpenRegisterInternal nor updateOrganisationInOpenRegister reads the OrganisationService it was handed. The public create wrapper still fetches it first, so a missing OpenRegister fails at the same point. The golden masks mock and anonymous class names, which depend on test order; it was re-recorded on the code before this commit. Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/StackiqService.php | 16 +++------------- .../StackiqServiceOrganisationSyncGoldenTest.php | 5 +++-- .../golden/stackiqServiceOrganisationSync.json | 16 ++++++++-------- 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/lib/Service/StackiqService.php b/lib/Service/StackiqService.php index 7fa7bcc4..c918112d 100644 --- a/lib/Service/StackiqService.php +++ b/lib/Service/StackiqService.php @@ -1364,7 +1364,6 @@ public function syncOrganizationWithOpenRegister(object $organizationObject): bo // Update the organization using OrganisationService. $updatedOrganisation = $this->updateOrganisationInOpenRegister( - organisationService: $organisationService, existingOrganisation: $existingOrganisation, mappedData: $mappedData ); @@ -1395,7 +1394,6 @@ public function syncOrganizationWithOpenRegister(object $organizationObject): bo // Create the organization using OrganisationService. $this->_logger->info('StackiqService: SYNC_STEP_10 - Calling createOrganisationInOpenRegister'); $createdOrganisation = $this->createOrganisationInOpenRegisterInternal( - organisationService: $organisationService, mappedData: $mappedData, organizationUuid: $organizationUuid ); @@ -1452,11 +1450,10 @@ public function createOrganisationInOpenRegister(array $objectData): ?object { 'participants' => $objectData['participants'] ?? [], ]; - // Get organisation service. - $organisationService = $this->_container->get('OCA\OpenRegister\Service\OrganisationService'); + // Fails early when OpenRegister's organisation service is not available. + $this->_container->get('OCA\OpenRegister\Service\OrganisationService'); return $this->createOrganisationInOpenRegisterInternal( - organisationService: $organisationService, mappedData: $mappedData, organizationUuid: $organizationUuid ); @@ -1494,17 +1491,12 @@ private function mapStatus(string $status): bool { /** * Creates an organization in OpenRegister using OrganisationService * - * @param \OCA\OpenRegister\Service\OrganisationService $organisationService The OpenRegister organisation service * @param array $mappedData The mapped organization data * @param string $organizationUuid The organization UUID to use * * @return \OCA\OpenRegister\Db\Organisation The created organization */ - private function createOrganisationInOpenRegisterInternal( - \OCA\OpenRegister\Service\OrganisationService $organisationService, - array $mappedData, - string $organizationUuid, - ): \OCA\OpenRegister\Db\Organisation { + private function createOrganisationInOpenRegisterInternal(array $mappedData, string $organizationUuid): \OCA\OpenRegister\Db\Organisation { $this->_logger->info( 'StackiqService: STEP 1 - Starting createOrganisationInOpenRegister', [ @@ -1778,14 +1770,12 @@ private function createOrganisationInOpenRegisterInternal( /** * Updates an organization in OpenRegister using OrganisationService * - * @param \OCA\OpenRegister\Service\OrganisationService $organisationService The OpenRegister organisation service * @param \OCA\OpenRegister\Db\Organisation $existingOrganisation The existing organization * @param array $mappedData The mapped organization data * * @return \OCA\OpenRegister\Db\Organisation The updated organization */ private function updateOrganisationInOpenRegister( - \OCA\OpenRegister\Service\OrganisationService $organisationService, \OCA\OpenRegister\Db\Organisation $existingOrganisation, array $mappedData, ): \OCA\OpenRegister\Db\Organisation { diff --git a/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php b/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php index 1f49ea4d..f94dd2cd 100644 --- a/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php +++ b/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php @@ -6,7 +6,8 @@ * Records, per case, every container, mapper, organisation entity, object * service, settings and logger call that syncOrganizationWithOpenRegister and * createOrganisationInOpenRegister make, plus their result or the error that - * escapes. Recorded with RECORD_GOLDEN=1. + * escapes. Mock and anonymous class names are masked, as they depend on the + * order tests run in. Recorded with RECORD_GOLDEN=1. * * @category Test * @package OCA\Stackiq\Tests\Unit\Service @@ -352,7 +353,7 @@ public function log($level, string|\Stringable $message, array $context = []): v $context, function (&$value) { if (is_string($value) === true) { - $value = preg_replace('/Mock\w+/', 'Mock', $value); + $value = preg_replace(['/Mock\w+/', '/@anonymous.*/s'], ['Mock', '@anonymous'], $value); } } ); diff --git a/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json b/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json index 9d2ec879..e7f29d15 100644 --- a/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json +++ b/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json @@ -7,7 +7,7 @@ "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", { "objectId": 17, - "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + "objectClass": "class@anonymous" } ], [ @@ -45,7 +45,7 @@ "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", { "objectId": 17, - "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + "objectClass": "class@anonymous" } ], [ @@ -117,7 +117,7 @@ "StackiqService: SYNC_STEP_6 - Organization exists in OpenRegister, updating", { "organizationId": "org-1", - "existingOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:243$c0" + "existingOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous" } ], [ @@ -173,7 +173,7 @@ "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", { "objectId": 17, - "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + "objectClass": "class@anonymous" } ], [ @@ -245,7 +245,7 @@ "StackiqService: SYNC_STEP_6 - Organization exists in OpenRegister, updating", { "organizationId": "org-1", - "existingOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:243$c0" + "existingOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous" } ], [ @@ -299,7 +299,7 @@ "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", { "objectId": 17, - "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + "objectClass": "class@anonymous" } ], [ @@ -514,7 +514,7 @@ "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", { "objectId": 17, - "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + "objectClass": "class@anonymous" } ], [ @@ -781,7 +781,7 @@ "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", { "objectId": 17, - "objectClass": "class@anonymous\u0000/home/rubenlinde/sig-lanes/sq3/stackiq/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php:298$c1" + "objectClass": "class@anonymous" } ], [ From f8610e68ad33598ed7f019a605fb5f7315350be8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 17:41:11 +0100 Subject: [PATCH 09/10] fix(organisations): create OpenRegister organisations through OrganisationService StackiqService created a signed-in user's organisation with OrganisationMapper::createWithUuid(), which OpenRegister does not have, and an anonymous one by saving an admin-owned entity and then calling getUID() on the null user. Both ended in an Error that escaped catch (\Exception), so creation never returned an organisation. A signed-in creation now goes through OrganisationService::createOrganisation() with addCurrentUser, so the creator becomes owner and member, and the organisation's contact persons are added with addUserToOrganisation(). Without a signed-in user nothing is created: the specs do not say who would own it. ContactpersoonService passed orgObject: to ensureOrganisationEntityPublic(), whose parameter is organisationObject, so its backup entity creation always failed with an Error. Tests: golden cases recorded on the old code for the anonymous, forged owner and member, creator-as-contact and missing-service paths (the Organisation stub answers magic accessors like the Entity does, so the anonymous case reaches the save it really makes); the backfill sends no mail and processSpecificOrganization mails on create; the koppelingen lookup cases for a forged owner filter, owner lookalikes and forged @self/isAmbtenaar options. Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/ContactpersoonService.php | 2 +- lib/Service/StackiqService.php | 277 +------- tests/Stubs/Db/Organisation.php | 48 +- tests/Stubs/Db/OrganisationMapper.php | 10 + tests/Stubs/Service/OrganisationService.php | 17 + ...AangebodenGebruikKoppelingenGoldenTest.php | 8 +- .../ContactpersoonBackupEntityTest.php | 174 +++++ .../OrganizationSyncEntityGoldenTest.php | 65 ++ ...ackiqServiceOrganisationSyncGoldenTest.php | 33 +- .../golden/aangebodenGebruikKoppelingen.json | 285 ++++++++ .../stackiqServiceOrganisationSync.json | 657 ++++++++---------- 11 files changed, 964 insertions(+), 612 deletions(-) create mode 100644 tests/Unit/Service/ContactpersoonBackupEntityTest.php diff --git a/lib/Service/ContactpersoonService.php b/lib/Service/ContactpersoonService.php index c157ca85..3a0b1221 100644 --- a/lib/Service/ContactpersoonService.php +++ b/lib/Service/ContactpersoonService.php @@ -193,7 +193,7 @@ public function processContactpersoon(object $contactPersonObject, bool $isUpdat 'entitiesUpdated' => 0, ]; $organisationEntity = $organizationSyncService->ensureOrganisationEntityPublic( - orgObject: $orgObject, + organisationObject: $orgObject, stats: $backupStats ); $this->logger->info( diff --git a/lib/Service/StackiqService.php b/lib/Service/StackiqService.php index c918112d..73e9811e 100644 --- a/lib/Service/StackiqService.php +++ b/lib/Service/StackiqService.php @@ -1489,278 +1489,51 @@ private function mapStatus(string $status): bool { }//end mapStatus() /** - * Creates an organization in OpenRegister using OrganisationService + * Creates an organization in OpenRegister through its OrganisationService * - * @param array $mappedData The mapped organization data + * The signed-in user creates it and becomes its owner and a member; the + * organisation's contact persons are added as members. Without a signed-in + * user nothing is created: no rule says who would own it. + * + * @param array $mappedData The mapped organization data * @param string $organizationUuid The organization UUID to use * * @return \OCA\OpenRegister\Db\Organisation The created organization + * + * @throws RuntimeException When no user is signed in, or OpenRegister is not available */ private function createOrganisationInOpenRegisterInternal(array $mappedData, string $organizationUuid): \OCA\OpenRegister\Db\Organisation { - $this->_logger->info( - 'StackiqService: STEP 1 - Starting createOrganisationInOpenRegister', - [ - 'organizationUuid' => $organizationUuid, - 'name' => $mappedData['name'] ?? 'Unknown', - 'mappedDataKeys' => array_keys($mappedData), - ] - ); - - // Check if we're in an anonymous context (no logged-in user). - $userSession = $this->_userSession; - $currentUser = $userSession->getUser(); - - $currentUserValue = 'null'; - if ($currentUser !== null) { - $currentUserValue = $currentUser->getUID(); + if ($this->_userSession->getUser() === null) { + throw new RuntimeException('An OpenRegister organisation is only created for a signed-in user'); } - $this->_logger->info( - 'StackiqService: STEP 2 - Checking user context', - [ - // Always true: $userSession is an injected, non-nullable IUserSession. - 'hasUserSession' => true, - 'currentUser' => $currentUserValue, - 'isAnonymous' => $currentUser === null, - ] - ); - - if ($currentUser === null) { - $this->_logger->info( - 'StackiqService: STEP 3A - Anonymous path: No user, creating org directly via mapper', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - // Keep the original UUID format - no conversion needed. - $this->_logger->info( - 'StackiqService: STEP 3B - Using original UUID format for OpenRegister (anonymous)', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - // Create organization directly via mapper to avoid user context requirements. - $this->_logger->info('StackiqService: STEP 3C - Getting OrganisationMapper from container'); - $organisationMapper = $this->getOrganisationMapper(); - if ($organisationMapper === null) { - // This method's return type is non-nullable and its caller already - // holds an OpenRegister OrganisationService, so "unavailable" here - // escapes exactly as the raw container exception used to. - throw new RuntimeException('OpenRegister OrganisationMapper is not available'); - } - - $this->_logger->info( - 'StackiqService: STEP 3D - OrganisationMapper retrieved', - [ - 'mapperClass' => get_class($organisationMapper), - ] - ); - - // Create a new Organisation entity. - $this->_logger->info('StackiqService: STEP 3E - Creating new Organisation entity'); - $organisation = new \OCA\OpenRegister\Db\Organisation(); - - $this->_logger->info( - 'StackiqService: STEP 3F - Setting organisation properties', - [ - 'name' => $mappedData['name'] ?? 'Unknown Organization', - 'description' => $mappedData['website'] ?? '', - 'uuid' => $organizationUuid, - ] - ); - - // Collect all contact person usernames for this organization. - $contactPersonUsernames = $this->collectContactPersonUsernames( - organizationUuid: $organizationUuid, - objectData: $mappedData - ); - - // Start with admin user and add all contact person usernames. - $allUsernames = array_merge(['admin'], $contactPersonUsernames); - $allUsernames = array_unique($allUsernames); - - $this->_logger->info( - 'StackiqService: STEP 3F_2 - Collected usernames for organization', - [ - 'organizationUuid' => $organizationUuid, - 'totalUsernames' => count($allUsernames), - 'usernames' => $allUsernames, - ] - ); - - $organisation->setName($mappedData['name'] ?? 'Unknown Organization'); - $organisation->setDescription($mappedData['website'] ?? ''); - // Use website as description. - $organisation->setUuid($organizationUuid); - $organisation->setUsers($allUsernames); - $organisation->setOwner('admin'); - // Set admin as owner for anonymous registrations. - $organisation->setActive($mappedData['active'] ?? true); - // Set active status based on organization beoordeling. - // Debug: Check if UUID was set correctly. - $this->_logger->info( - 'StackiqService: STEP 3G - Debug - UUID before save', - [ - 'setUuid' => $organizationUuid, - 'getUuid' => $organisation->getUuid(), - 'uuidMatches' => $organisation->getUuid() === $organizationUuid, - 'organisationClass' => get_class($organisation), - ] - ); - - // Save the organization. - $this->_logger->info('StackiqService: STEP 3H - Calling organisationMapper->save()'); - try { - $savedOrganisation = $organisationMapper->save($organisation); - $this->_logger->info( - 'StackiqService: STEP 3I - organisationMapper->save() completed' - ); - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: STEP 3I - organisationMapper->save() failed', - [ - 'error' => $e->getMessage(), - 'errorClass' => get_class($e), - 'trace' => $e->getTraceAsString(), - ] - ); - throw $e; - } - - $this->_logger->info( - 'StackiqService: Successfully created organization in OpenRegister via mapper', - [ - 'organizationUuid' => $organizationUuid, - 'openRegisterId' => $savedOrganisation->getUuid(), - 'savedUuid' => $savedOrganisation->getUuid(), - 'expectedUuid' => $organizationUuid, - ] - ); - - // Verify the UUID was preserved. - if ($savedOrganisation->getUuid() !== $organizationUuid) { - $this->_logger->warning( - 'StackiqService: UUID mismatch after saving organization', - [ - 'expectedUuid' => $organizationUuid, - 'actualUuid' => $savedOrganisation->getUuid(), - ] - ); - } - }//end if - - $this->_logger->info( - 'StackiqService: STEP 4A - Auth path: User logged in, creating org via mapper', - [ - 'organizationUuid' => $organizationUuid, - 'currentUser' => $currentUser->getUID(), - ] - ); - - // Keep the original UUID format - no conversion needed. - $this->_logger->info( - 'StackiqService: STEP 4B - Using original UUID format for OpenRegister', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - // Create organization directly via mapper to avoid service issues. - $this->_logger->info('StackiqService: STEP 4C - Getting OrganisationMapper from container'); - $organisationMapper = $this->getOrganisationMapper(); - if ($organisationMapper === null) { - // Non-nullable return type, same reasoning as the anonymous branch above. - throw new RuntimeException('OpenRegister OrganisationMapper is not available'); + $organisationService = $this->getOrganisationService(); + $organisationMapper = $this->getOrganisationMapper(); + if ($organisationService === null || $organisationMapper === null) { + throw new RuntimeException('OpenRegister organisations are not available'); } - $this->_logger->info( - 'StackiqService: STEP 4D - OrganisationMapper retrieved', - [ - 'mapperClass' => get_class($organisationMapper), - ] + $organisation = $organisationService->createOrganisation( + name: $mappedData['name'] ?? 'Unknown Organization', + description: $mappedData['website'] ?? '', + addCurrentUser: true, + uuid: $organizationUuid ); - // Debug: Check UUID before creating. - // Collect all contact person usernames for this organization. $contactPersonUsernames = $this->collectContactPersonUsernames( organizationUuid: $organizationUuid, objectData: $mappedData ); + foreach (array_diff(array_unique($contactPersonUsernames), $organisation->getUserIds()) as $username) { + $organisation = $organisationMapper->addUserToOrganisation($organisation->getUuid(), $username); + } - // Start with current user and add all contact person usernames. - $allUsernames = array_merge([$currentUser->getUID()], $contactPersonUsernames); - $allUsernames = array_unique($allUsernames); - - $this->_logger->info( - 'StackiqService: STEP 4E - Debug - UUID before createWithUuid', - [ - 'organizationUuid' => $organizationUuid, - 'uuidLength' => strlen($organizationUuid), - 'uuidIsEmpty' => empty($organizationUuid) === true, - 'name' => $mappedData['name'] ?? 'Unknown Organization', - 'description' => $mappedData['website'] ?? '', - 'owner' => $currentUser->getUID(), - 'users' => $allUsernames, - 'contactPersonUsernames' => $contactPersonUsernames, - ] - ); - - $this->_logger->info('StackiqService: STEP 4F - Calling organisationMapper->createWithUuid()'); - try { - // Debug: Log the exact parameters being passed. - $this->_logger->info( - 'StackiqService: STEP 4F_DEBUG - Parameters for createWithUuid', - [ - 'name' => $mappedData['name'] ?? 'Unknown Organization', - 'description' => $mappedData['website'] ?? '', - 'uuid' => $organizationUuid, - 'owner' => $currentUser->getUID(), - 'users' => $allUsernames, - 'isDefault' => false, - 'uuidLength' => strlen($organizationUuid), - 'uuidIsEmpty' => empty($organizationUuid) === true, - ] - ); - - $organisation = $organisationMapper->createWithUuid( - $mappedData['name'] ?? 'Unknown Organization', - $mappedData['website'] ?? '', - // Use website as description. - $organizationUuid, - // Pass the original UUID. - $currentUser->getUID(), - // Set current user as owner. - $allUsernames, - // Add all users including contact persons. - false - // Not default. - ); - $this->_logger->info( - 'StackiqService: STEP 4G - organisationMapper->createWithUuid() completed' - ); - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: STEP 4G - organisationMapper->createWithUuid() failed', - [ - 'error' => $e->getMessage(), - 'errorClass' => get_class($e), - 'trace' => $e->getTraceAsString(), - ] - ); - throw $e; - }//end try - - // Note: OpenRegister Organisation entity doesn't have status or type fields. - // These are managed in the Stackiq object, not in the OpenRegister organisation. $this->_logger->info( - 'StackiqService: Successfully created organization in OpenRegister via service', + 'StackiqService: Created organization in OpenRegister', [ 'organizationUuid' => $organizationUuid, - 'openRegisterId' => $organisation->getUuid(), - 'savedUuid' => $organisation->getUuid(), - 'expectedUuid' => $organizationUuid, + 'openRegisterId' => $organisation->getUuid(), + 'contactPersons' => count($contactPersonUsernames), ] ); diff --git a/tests/Stubs/Db/Organisation.php b/tests/Stubs/Db/Organisation.php index 6a79b148..99de7d97 100644 --- a/tests/Stubs/Db/Organisation.php +++ b/tests/Stubs/Db/Organisation.php @@ -5,7 +5,10 @@ * * The real Organisation is an NC AppFramework Entity whose accessors are * partly magic. This stub declares the concrete surface the Stackiq - * unit tests exercise (uuid + parent + a couple of read accessors). + * unit tests exercise (uuid + parent + a couple of read accessors), and + * answers the other accessors and the membership helpers (getUserIds, + * addUser) through __call, as the Entity does. Test doubles that extend it + * and override __call keep recording those calls. * * SPDX-License-Identifier: EUPL-1.2 * @@ -42,6 +45,13 @@ class Organisation { */ private bool $active = true; + /** + * The magic properties, by name. + * + * @var array + */ + private array $properties = []; + /** * @param int|null $id The id. * @@ -107,4 +117,40 @@ public function isActive(): bool { return $this->active; }//end isActive() + /** + * Answer the magic accessors and the membership helpers. + * + * @param string $name The method. + * @param array $args The arguments. + * + * @return mixed + */ + public function __call(string $name, array $args) { + if ($name === 'getUserIds') { + return $this->properties['users'] ?? []; + } + + if ($name === 'addUser') { + $users = $this->properties['users'] ?? []; + if (in_array($args[0], $users, true) === false) { + $users[] = $args[0]; + } + + $this->properties['users'] = $users; + return $this; + } + + $property = lcfirst(substr($name, 3)); + if (str_starts_with($name, 'set') === true) { + $this->properties[$property] = $args[0] ?? null; + return $this; + } + + if (str_starts_with($name, 'get') === true) { + return $this->properties[$property] ?? null; + } + + throw new \BadFunctionCallException($name . ' is not a valid method'); + }//end __call() + }//end class diff --git a/tests/Stubs/Db/OrganisationMapper.php b/tests/Stubs/Db/OrganisationMapper.php index e7618229..b4eb0f1a 100644 --- a/tests/Stubs/Db/OrganisationMapper.php +++ b/tests/Stubs/Db/OrganisationMapper.php @@ -49,4 +49,14 @@ abstract public function findBySlug(string $slug): Organisation; */ abstract public function save(Organisation $entity): Organisation; + /** + * Add a user to an organisation. + * + * @param string $organisationUuid The organisation uuid. + * @param string $userId The user id. + * + * @return Organisation + */ + abstract public function addUserToOrganisation(string $organisationUuid, string $userId): Organisation; + }//end class diff --git a/tests/Stubs/Service/OrganisationService.php b/tests/Stubs/Service/OrganisationService.php index d535023c..bb1695b5 100644 --- a/tests/Stubs/Service/OrganisationService.php +++ b/tests/Stubs/Service/OrganisationService.php @@ -79,4 +79,21 @@ abstract public function joinOrganisation(string $organisationUuid, ?string $tar */ abstract public function leaveOrganisation(string $organisationUuid, ?string $targetUserId = null): bool; + /** + * Create an organisation; with addCurrentUser the signed-in user becomes its owner and a member. + * + * @param string $name The name. + * @param string $description The description. + * @param bool $addCurrentUser Whether the signed-in user owns and joins it. + * @param string $uuid The uuid to use. + * + * @return Organisation + */ + abstract public function createOrganisation( + string $name, + string $description = '', + bool $addCurrentUser = true, + string $uuid = '', + ): Organisation; + }//end class diff --git a/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php b/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php index 301f430a..f994db1c 100644 --- a/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php +++ b/tests/Unit/Service/AangebodenGebruikKoppelingenGoldenTest.php @@ -103,6 +103,11 @@ public static function cases(): array { 'ownership lookup throws' => ['uuid-mod', [], false, ['findThrows' => true]], 'ownership object missing' => ['uuid-mod', [], false, ['found' => null]], 'search throws' => ['uuid-mod', [], true, ['found' => $module, 'searchThrows' => true]], + 'not the owner, filter names the owner' => ['uuid-mod', ['organisation' => 'org-other'], false, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'owner in another case' => ['uuid-mod', [], false, ['found' => ['owner' => 'ORG-ACTING', 'schema' => 'schema-module']]], + 'owner with trailing space' => ['uuid-mod', [], false, ['found' => ['owner' => self::ACTING_ORG . ' ', 'schema' => 'schema-module']]], + 'forged @self and ambtenaar options' => ['uuid-mod', ['@self' => ['organisation' => self::ACTING_ORG, 'register' => 'reg-forged'], 'isAmbtenaar' => true, 'organisation' => 'org-other'], false, ['found' => ['owner' => 'org-other', 'schema' => 'schema-module']]], + 'owner, forged @self in options' => ['uuid-mod', ['@self' => ['register' => 'reg-forged', 'schema' => 'schema-forged']], false, ['found' => $module]], ]; }//end cases() @@ -236,7 +241,8 @@ function (...$args) { $objectService->method('buildSearchQuery')->willReturnCallback( function (array $requestParams): array { $this->trace[] = ['objects.buildSearchQuery', $requestParams]; - return ['@self' => ['seed' => true], '_limit' => $requestParams['_limit'] ?? 20]; + // Like OpenRegister, a caller's @self filters are carried into the query. + return ['@self' => ['seed' => true] + ($requestParams['@self'] ?? []), '_limit' => $requestParams['_limit'] ?? 20]; } ); $objectService->method('searchObjectsPaginated')->willReturnCallback( diff --git a/tests/Unit/Service/ContactpersoonBackupEntityTest.php b/tests/Unit/Service/ContactpersoonBackupEntityTest.php new file mode 100644 index 00000000..f985d10b --- /dev/null +++ b/tests/Unit/Service/ContactpersoonBackupEntityTest.php @@ -0,0 +1,174 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/method-decomposition/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Service; + +use OCA\OpenRegister\Db\OrganisationMapper; +use OCA\Stackiq\Service\ContactpersoonService; +use OCA\Stackiq\Service\OrganizationSyncService; +use OCA\Stackiq\Service\SettingsService; +use OCA\Stackiq\Service\Stackiq\ContactPersonHandler; +use OCA\Stackiq\Service\Stackiq\GroupHandler; +use OCA\Stackiq\Service\Stackiq\HierarchyHandler; +use OCP\App\IAppManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IAppConfig; +use OCP\IUserManager; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Psr\Log\NullLogger; + +/** + * A contact person of an active organisation without an entity triggers the backup creation. + * + * @covers \OCA\Stackiq\Service\ContactpersoonService + * + * @spec openspec/specs/method-decomposition/spec.md + */ +class ContactpersoonBackupEntityTest extends TestCase { + + /** + * The organisation object the backup entity is created from. + * + * @return void + */ + public function testTheBackupEntityIsCreatedFromTheOrganisationObject(): void { + $organisation = $this->stored(id: 'org-1', data: ['status' => 'Actief']); + $calls = []; + + $sync = $this->createMock(OrganizationSyncService::class); + $sync->method('ensureOrganisationEntityPublic')->willReturnCallback( + function (object $organisationObject, array &$stats, bool $sendEmails = true) use (&$calls) { + $calls[] = [$organisationObject, $sendEmails]; + return null; + } + ); + + $this->service(organisation: $organisation, sync: $sync)->processContactpersoon( + $this->stored(id: 'contact-backup-1', data: ['email' => 'new@gemeente.nl', 'organisation' => 'org-1']) + ); + + self::assertCount(1, $calls); + self::assertSame($organisation, $calls[0][0]); + self::assertTrue($calls[0][1], 'the backup creation keeps the default: registration mails are sent'); + }//end testTheBackupEntityIsCreatedFromTheOrganisationObject() + + /** + * An inactive organisation gets no backup entity. + * + * @return void + */ + public function testAnInactiveOrganisationGetsNoBackupEntity(): void { + $sync = $this->createMock(OrganizationSyncService::class); + $sync->expects(self::never())->method('ensureOrganisationEntityPublic'); + + $this->service(organisation: $this->stored(id: 'org-1', data: ['status' => 'inactief']), sync: $sync)->processContactpersoon( + $this->stored(id: 'contact-backup-2', data: ['email' => 'new@gemeente.nl', 'organisation' => 'org-1']) + ); + }//end testAnInactiveOrganisationGetsNoBackupEntity() + + /** + * The service over a container whose organisation has no OpenRegister entity yet. + * + * @param object $organisation The stored organisation object. + * @param OrganizationSyncService $sync The sync service double. + * + * @return ContactpersoonService + */ + private function service(object $organisation, OrganizationSyncService $sync): ContactpersoonService { + $users = $this->createMock(IUserManager::class); + $users->method('get')->willReturn(null); + + $mapper = $this->createMock(OrganisationMapper::class); + $mapper->method('findByUuid')->willThrowException(new DoesNotExistException('missing')); + + $objects = new class($organisation) { + /** + * @param object $organisation The stored organisation. + */ + public function __construct(private object $organisation) { + } + + /** + * @param mixed ...$args The lookup. + * + * @return object + */ + public function find(...$args): object { + return $this->organisation; + } + }; + + $settings = $this->createMock(SettingsService::class); + $settings->method('getVoorzieningenConfig')->willReturn([]); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnCallback( + fn (string $id) => [ + 'OCP\IUserManager' => $users, + 'OCA\OpenRegister\Db\OrganisationMapper' => $mapper, + 'OCA\OpenRegister\Service\ObjectService' => $objects, + 'OCA\Stackiq\Service\SettingsService' => $settings, + 'OCA\Stackiq\Service\OrganizationSyncService' => $sync, + ][$id] ?? null + ); + + return new ContactpersoonService( + contactPersonHandler: $this->createMock(ContactPersonHandler::class), + groupHandler: $this->createMock(GroupHandler::class), + hierarchyHandler: $this->createMock(HierarchyHandler::class), + logger: new NullLogger(), + container: $container, + appManager: $this->createMock(IAppManager::class), + config: $this->createMock(IAppConfig::class), + settingsService: $settings + ); + }//end service() + + /** + * A stored object double. + * + * @param string $id The id. + * @param array $data The data. + * + * @return object + */ + private function stored(string $id, array $data): object { + return new class($id, $data) { + /** + * @param string $id The id. + * @param array $data The data. + */ + public function __construct(private string $id, private array $data) { + } + + /** + * @return string + */ + public function getId(): string { + return $this->id; + } + + /** + * @return array + */ + public function getObject(): array { + return $this->data; + } + }; + }//end stored() +}//end class diff --git a/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php b/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php index 4d9a86a6..952aebe9 100644 --- a/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php +++ b/tests/Unit/Service/OrganizationSyncEntityGoldenTest.php @@ -601,6 +601,71 @@ public function testEnsureEntity(array $object, bool $sendEmails, \Closure $plan }//end testEnsureEntity() + /** + * The batch back-fill creates missing organisation entities without mailing them. + * + * @return void + */ + public function testTheBackfillSendsNoMail(): void { + $this->trace = []; + $missing = new \OCP\AppFramework\Db\DoesNotExistException('missing'); + $this->plan = [ + 'config' => ['register' => '6', 'organisatie_schema' => '9'], + 'find' => [$this->entity(object: ['name' => 'Gemeente N', 'status' => 'actief'], uuid: 'o-1'), null], + 'save' => [], + 'org' => [$missing], + 'slug' => [$missing], + ]; + $service = $this->service(); + (new \ReflectionProperty(OrganizationSyncService::class, 'db'))->setValue($service, $this->batchDatabase(rows: [['uuid' => 'o-1', 'status' => 'actief']])); + + $stats = $service->performOrganizationsSync(); + + self::assertSame(1, $stats['entitiesCreated'], 'the back-fill must reach the entity creation'); + self::assertSame([], array_values(array_filter($this->trace, fn (array $call) => str_starts_with($call[0], 'mail.')))); + }//end testTheBackfillSendsNoMail() + + /** + * A created organisation processed on its own gets its registration mail. + * + * @return void + */ + public function testProcessSpecificOrganizationMailsOnCreate(): void { + $this->trace = []; + $missing = new \OCP\AppFramework\Db\DoesNotExistException('missing'); + $this->plan = ['find' => [null], 'save' => [], 'org' => [$missing], 'slug' => [$missing]]; + + $this->service()->processSpecificOrganization($this->entity(object: ['name' => 'Gemeente O', 'status' => 'actief'], uuid: 'o-1')); + + self::assertContains(['mail.sendOrganizationRegistrationEmail', 'Gemeente O'], $this->trace); + }//end testProcessSpecificOrganizationMailsOnCreate() + + /** + * A database whose count and batch queries answer the given rows. + * + * @param array> $rows The organisations without an entity. + * + * @return \OCP\IDBConnection + */ + private function batchDatabase(array $rows): \OCP\IDBConnection { + $result = $this->createMock(originalClassName: \OCP\DB\IResult::class); + $result->method('fetchOne')->willReturn(count($rows)); + $result->method('fetchAll')->willReturn($rows); + + $query = $this->createMock(originalClassName: \OCP\DB\QueryBuilder\IQueryBuilder::class); + foreach (['select', 'from', 'leftJoin', 'where', 'andWhere', 'orderBy', 'setMaxResults'] as $method) { + $query->method($method)->willReturnSelf(); + } + + $query->method('createFunction')->willReturn($this->createMock(originalClassName: \OCP\DB\QueryBuilder\IQueryFunction::class)); + $query->method('createNamedParameter')->willReturn(':status'); + $query->method('executeQuery')->willReturn($result); + + $database = $this->createMock(originalClassName: \OCP\IDBConnection::class); + $database->method('getQueryBuilder')->willReturn($query); + return $database; + }//end batchDatabase() + /** * Compare a case with the golden file, or record it. * diff --git a/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php b/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php index f94dd2cd..887003e4 100644 --- a/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php +++ b/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php @@ -97,10 +97,15 @@ public static function cases(): array { 'sync, new, signed in' => ['sync', $org, []], 'sync, new, signed in with schema' => ['sync', $org, ['schema' => true, 'contacts' => [['email' => 'b@a.nl', 'organization' => 'org-1'], ['email' => 'c@a.nl', 'organization' => 'org-9']]]], 'sync, new, anonymous' => ['sync', $org, ['anonymous' => true]], + 'sync, new, organisation service down' => ['sync', $org, ['serviceThrows' => true]], 'create, no id' => ['create', ['name' => 'X'], []], 'create, signed in' => ['create', $org, []], 'create, organisation service down' => ['create', $org, ['serviceThrows' => true]], 'create, anonymous, no mapper' => ['create', $org, ['anonymous' => true, 'noMapper' => true]], + 'create, anonymous' => ['create', $org, ['anonymous' => true]], + 'create, forged owner and members' => ['create', $org + ['owner' => 'mallory', 'users' => ['mallory'], 'participants' => ['mallory']], []], + 'create, creator is a contact' => ['create', ['contactpersonen' => [['email' => 'signed-in'], ['email' => 'a@a.nl']]] + $org, []], + 'create, no mapper' => ['create', $org, ['noMapper' => true]], ]; }//end cases() @@ -156,6 +161,12 @@ function (): array { } ); $organisations = $this->createMock(OrganisationService::class); + $organisations->method('createOrganisation')->willReturnCallback( + function (string $name, string $description = '', bool $addCurrentUser = true, string $uuid = '') { + $this->trace[] = ['organisations.createOrganisation', $name, $description, $addCurrentUser, $uuid]; + return $this->organisation(uuid: $uuid, users: ['signed-in']); + } + ); $container = $this->createMock(ContainerInterface::class); $container->method('get')->willReturnCallback( @@ -217,6 +228,12 @@ function (string $uuid) { return $this->organisation(uuid: $uuid); } ); + $mapper->method('addUserToOrganisation')->willReturnCallback( + function (string $uuid, string $userId) { + $this->trace[] = ['mapper.addUserToOrganisation', $uuid, $userId]; + return $this->organisation(uuid: $uuid, users: ['signed-in', $userId]); + } + ); $mapper->method('save')->willReturnCallback( function ($organisation) { $this->trace[] = ['mapper.save', $organisation->getUuid()]; @@ -235,17 +252,19 @@ function ($organisation) { /** * An organisation entity double that records its writes. * - * @param string $uuid The uuid. + * @param string $uuid The uuid. + * @param array $users The member user ids getUserIds() answers. * * @return Organisation */ - private function organisation(string $uuid): Organisation { + private function organisation(string $uuid, array $users = []): Organisation { $trace = &$this->trace; - $organisation = new class($trace) extends Organisation { + $organisation = new class($trace, $users) extends Organisation { /** - * @param array $trace The shared trace. + * @param array $trace The shared trace. + * @param array $users The member user ids. */ - public function __construct(private array &$trace) { + public function __construct(private array &$trace, private array $users) { } /** @@ -256,6 +275,10 @@ public function __construct(private array &$trace) { */ public function __call(string $name, array $args) { $this->trace[] = ['organisation.' . $name, $args]; + if ($name === 'getUserIds') { + return $this->users; + } + return null; } }; diff --git a/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json b/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json index fcde4005..9a435c71 100644 --- a/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json +++ b/tests/Unit/Service/golden/aangebodenGebruikKoppelingen.json @@ -1365,5 +1365,290 @@ } ] ] + }, + "[\"uuid-mod\",{\"organisation\":\"org-other\"},false,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "organisation": "org-other" + }, + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":{\"owner\":\"ORG-ACTING\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",[],false,{\"found\":{\"owner\":\"org-acting \",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": [], + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",{\"@self\":{\"organisation\":\"org-acting\",\"register\":\"reg-forged\"},\"isAmbtenaar\":true,\"organisation\":\"org-other\"},false,{\"found\":{\"owner\":\"org-other\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [], + "total": 0, + "page": 1, + "pages": 0, + "limit": 20, + "offset": 0 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "@self": { + "organisation": "org-acting", + "register": "reg-forged" + }, + "isAmbtenaar": true, + "organisation": "org-other" + }, + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ] + ] + }, + "[\"uuid-mod\",{\"@self\":{\"register\":\"reg-forged\",\"schema\":\"schema-forged\"}},false,{\"found\":{\"owner\":\"org-acting\",\"schema\":\"schema-module\"}}]": { + "result": { + "results": [ + { + "id": "g-1" + } + ], + "total": 1, + "page": 1, + "pages": 1 + }, + "calls": [ + [ + "log.info", + "Getting koppelingen and gebruiks for UUID with extended access", + { + "uuid": "uuid-mod", + "options": { + "@self": { + "register": "reg-forged", + "schema": "schema-forged" + } + }, + "isAmbtenaar": false + } + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "organisations.getActiveOrganisation" + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "objects.buildSearchQuery", + { + "@self": { + "register": "reg-forged", + "schema": "schema-forged" + } + } + ], + [ + "objects.find", + "uuid-mod", + false, + false + ], + [ + "log.debug", + "Executing koppelingen-gebruik by suite/module UUID", + { + "uuid": "uuid-mod", + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + } + } + ], + [ + "objects.searchObjectsPaginated", + { + "query": { + "@self": { + "seed": true, + "register": "reg-1", + "schema": [ + "schema-gebruik", + "schema-koppeling" + ] + }, + "_limit": 20, + "_source": "database" + }, + "_rbac": false, + "_multitenancy": false, + "deleted": false, + "uses": "uuid-mod" + } + ], + [ + "log.debug", + "Koppelingen-gebruik by UUID search completed", + { + "uuid": "uuid-mod", + "total": 1, + "results_count": 1 + } + ] + ] } } diff --git a/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json b/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json index e7f29d15..09a0cc8a 100644 --- a/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json +++ b/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json @@ -289,10 +289,7 @@ ] }, "sync: sync, new, signed in": { - "result": { - "escaped": "Error", - "message": "Call to undefined method Mock::createWithUuid()" - }, + "result": true, "calls": [ [ "log.info", @@ -385,60 +382,19 @@ [] ], [ - "log.info", - "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", - { - "organizationUuid": "org-1", - "name": "Gemeente A", - "mappedDataKeys": [ - "name", - "type", - "website", - "active", - "contactpersonen", - "participants" - ] - } - ], - [ - "log.info", - "StackiqService: STEP 2 - Checking user context", - { - "hasUserSession": true, - "currentUser": "signed-in", - "isAnonymous": false - } - ], - [ - "log.info", - "StackiqService: STEP 4A - Auth path: User logged in, creating org via mapper", - { - "organizationUuid": "org-1", - "currentUser": "signed-in" - } - ], - [ - "log.info", - "StackiqService: STEP 4B - Using original UUID format for OpenRegister", - { - "organizationUuid": "org-1" - } - ], - [ - "log.info", - "StackiqService: STEP 4C - Getting OrganisationMapper from container", - [] + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" ], [ "container.get", "OCA\\OpenRegister\\Db\\OrganisationMapper" ], [ - "log.info", - "StackiqService: STEP 4D - OrganisationMapper retrieved", - { - "mapperClass": "Mock" - } + "organisations.createOrganisation", + "Gemeente A", + "https://a.nl", + true, + "org-1" ], [ "log.info", @@ -464,50 +420,32 @@ "StackiqService: Missing contactpersoon schema config for username extraction", [] ], + [ + "organisation.getUserIds", + [] + ], [ "log.info", - "StackiqService: STEP 4E - Debug - UUID before createWithUuid", + "StackiqService: Created organization in OpenRegister", { "organizationUuid": "org-1", - "uuidLength": 5, - "uuidIsEmpty": false, - "name": "Gemeente A", - "description": "https://a.nl", - "owner": "signed-in", - "users": [ - "signed-in" - ], - "contactPersonUsernames": [] + "openRegisterId": "org-1", + "contactPersons": 0 } ], [ "log.info", - "StackiqService: STEP 4F - Calling organisationMapper->createWithUuid()", - [] - ], - [ - "log.info", - "StackiqService: STEP 4F_DEBUG - Parameters for createWithUuid", + "StackiqService: SYNC_STEP_11 - Successfully created organization in OpenRegister", { - "name": "Gemeente A", - "description": "https://a.nl", - "uuid": "org-1", - "owner": "signed-in", - "users": [ - "signed-in" - ], - "isDefault": false, - "uuidLength": 5, - "uuidIsEmpty": false + "organizationId": "org-1", + "openRegisterId": "org-1", + "createdOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous" } ] ] }, "sync: sync, new, signed in with schema": { - "result": { - "escaped": "Error", - "message": "Call to undefined method Mock::createWithUuid()" - }, + "result": true, "calls": [ [ "log.info", @@ -600,60 +538,19 @@ [] ], [ - "log.info", - "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", - { - "organizationUuid": "org-1", - "name": "Gemeente A", - "mappedDataKeys": [ - "name", - "type", - "website", - "active", - "contactpersonen", - "participants" - ] - } - ], - [ - "log.info", - "StackiqService: STEP 2 - Checking user context", - { - "hasUserSession": true, - "currentUser": "signed-in", - "isAnonymous": false - } - ], - [ - "log.info", - "StackiqService: STEP 4A - Auth path: User logged in, creating org via mapper", - { - "organizationUuid": "org-1", - "currentUser": "signed-in" - } - ], - [ - "log.info", - "StackiqService: STEP 4B - Using original UUID format for OpenRegister", - { - "organizationUuid": "org-1" - } - ], - [ - "log.info", - "StackiqService: STEP 4C - Getting OrganisationMapper from container", - [] + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" ], [ "container.get", "OCA\\OpenRegister\\Db\\OrganisationMapper" ], [ - "log.info", - "StackiqService: STEP 4D - OrganisationMapper retrieved", - { - "mapperClass": "Mock" - } + "organisations.createOrganisation", + "Gemeente A", + "https://a.nl", + true, + "org-1" ], [ "log.info", @@ -724,57 +621,42 @@ ] } ], + [ + "organisation.getUserIds", + [] + ], + [ + "mapper.addUserToOrganisation", + "org-1", + "b@a.nl" + ], + [ + "mapper.addUserToOrganisation", + "org-1", + "c@a.nl" + ], [ "log.info", - "StackiqService: STEP 4E - Debug - UUID before createWithUuid", + "StackiqService: Created organization in OpenRegister", { "organizationUuid": "org-1", - "uuidLength": 5, - "uuidIsEmpty": false, - "name": "Gemeente A", - "description": "https://a.nl", - "owner": "signed-in", - "users": [ - "signed-in", - "b@a.nl", - "c@a.nl" - ], - "contactPersonUsernames": [ - "b@a.nl", - "c@a.nl" - ] + "openRegisterId": "org-1", + "contactPersons": 2 } ], [ "log.info", - "StackiqService: STEP 4F - Calling organisationMapper->createWithUuid()", - [] - ], - [ - "log.info", - "StackiqService: STEP 4F_DEBUG - Parameters for createWithUuid", + "StackiqService: SYNC_STEP_11 - Successfully created organization in OpenRegister", { - "name": "Gemeente A", - "description": "https://a.nl", - "uuid": "org-1", - "owner": "signed-in", - "users": [ - "signed-in", - "b@a.nl", - "c@a.nl" - ], - "isDefault": false, - "uuidLength": 5, - "uuidIsEmpty": false + "organizationId": "org-1", + "openRegisterId": "org-1", + "createdOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous" } ] ] }, "sync: sync, new, anonymous": { - "result": { - "escaped": "Error", - "message": "Call to undefined method OCA\\OpenRegister\\Db\\Organisation::setName()" - }, + "result": false, "calls": [ [ "log.info", @@ -867,80 +749,66 @@ [] ], [ - "log.info", - "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", - { - "organizationUuid": "org-1", - "name": "Gemeente A", - "mappedDataKeys": [ - "name", - "type", - "website", - "active", - "contactpersonen", - "participants" - ] - } - ], - [ - "log.info", - "StackiqService: STEP 2 - Checking user context", + "log.error", + "StackiqService: Failed to sync organization with OpenRegister: An OpenRegister organisation is only created for a signed-in user", { - "hasUserSession": true, - "currentUser": "null", - "isAnonymous": true + "objectId": 17, + "exception": "An OpenRegister organisation is only created for a signed-in user", + "file": "(file)", + "line": "(line)", + "trace": "(trace)" } - ], + ] + ] + }, + "create: create, no id": { + "result": null, + "calls": [ [ - "log.info", - "StackiqService: STEP 3A - Anonymous path: No user, creating org directly via mapper", - { - "organizationUuid": "org-1" - } - ], + "log.error", + "StackiqService: No organization UUID provided for creation", + [] + ] + ] + }, + "create: create, signed in": { + "result": "entity:org-1", + "calls": [ [ - "log.info", - "StackiqService: STEP 3B - Using original UUID format for OpenRegister (anonymous)", - { - "organizationUuid": "org-1" - } + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" ], [ - "log.info", - "StackiqService: STEP 3C - Getting OrganisationMapper from container", - [] + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" ], [ "container.get", "OCA\\OpenRegister\\Db\\OrganisationMapper" ], [ - "log.info", - "StackiqService: STEP 3D - OrganisationMapper retrieved", - { - "mapperClass": "Mock" - } - ], - [ - "log.info", - "StackiqService: STEP 3E - Creating new Organisation entity", - [] + "organisations.createOrganisation", + "Gemeente A", + "https://a.nl", + true, + "org-1" ], [ "log.info", - "StackiqService: STEP 3F - Setting organisation properties", + "StackiqService: Processing nested contact persons", { - "name": "Gemeente A", - "description": "https://a.nl", - "uuid": "org-1" + "organizationUuid": "org-1", + "nestedContactPersonCount": 3 } ], [ "log.info", - "StackiqService: Processing nested contact persons", + "StackiqService: Added nested contact person username", { - "organizationUuid": "org-1", - "nestedContactPersonCount": 0 + "username": "a@a.nl", + "contactPersonData": { + "email": "a@a.nl" + } } ], [ @@ -959,101 +827,233 @@ "StackiqService: Missing contactpersoon schema config for username extraction", [] ], + [ + "organisation.getUserIds", + [] + ], + [ + "mapper.addUserToOrganisation", + "org-1", + "a@a.nl" + ], [ "log.info", - "StackiqService: STEP 3F_2 - Collected usernames for organization", + "StackiqService: Created organization in OpenRegister", { "organizationUuid": "org-1", - "totalUsernames": 1, - "usernames": [ - "admin" - ] + "openRegisterId": "org-1", + "contactPersons": 1 } ] ] }, - "create: create, no id": { + "create: create, organisation service down": { "result": null, "calls": [ + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], [ "log.error", - "StackiqService: No organization UUID provided for creation", - [] + "StackiqService: Error in public createOrganisationInOpenRegister", + { + "error": "organisation service down", + "objectData": { + "id": "org-1", + "name": "Gemeente A", + "website": "https://a.nl", + "beoordeling": "actief", + "contactpersonen": [ + { + "email": "a@a.nl" + }, + { + "naam": "zonder mail" + }, + "c-2" + ] + } + } ] ] }, - "create: create, signed in": { - "result": { - "escaped": "Error", - "message": "Call to undefined method Mock::createWithUuid()" - }, + "create: create, anonymous, no mapper": { + "result": null, "calls": [ [ "container.get", "OCA\\OpenRegister\\Service\\OrganisationService" ], [ - "log.info", - "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", + "log.error", + "StackiqService: Error in public createOrganisationInOpenRegister", { - "organizationUuid": "org-1", - "name": "Gemeente A", - "mappedDataKeys": [ - "name", - "type", - "website", - "active", - "contactpersonen", - "participants" - ] + "error": "An OpenRegister organisation is only created for a signed-in user", + "objectData": { + "id": "org-1", + "name": "Gemeente A", + "website": "https://a.nl", + "beoordeling": "actief", + "contactpersonen": [ + { + "email": "a@a.nl" + }, + { + "naam": "zonder mail" + }, + "c-2" + ] + } } + ] + ] + }, + "create: create, anonymous": { + "result": null, + "calls": [ + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" ], [ - "log.info", - "StackiqService: STEP 2 - Checking user context", + "log.error", + "StackiqService: Error in public createOrganisationInOpenRegister", { - "hasUserSession": true, - "currentUser": "signed-in", - "isAnonymous": false + "error": "An OpenRegister organisation is only created for a signed-in user", + "objectData": { + "id": "org-1", + "name": "Gemeente A", + "website": "https://a.nl", + "beoordeling": "actief", + "contactpersonen": [ + { + "email": "a@a.nl" + }, + { + "naam": "zonder mail" + }, + "c-2" + ] + } } + ] + ] + }, + "create: create, forged owner and members": { + "result": "entity:org-1", + "calls": [ + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "organisations.createOrganisation", + "Gemeente A", + "https://a.nl", + true, + "org-1" ], [ "log.info", - "StackiqService: STEP 4A - Auth path: User logged in, creating org via mapper", + "StackiqService: Processing nested contact persons", { "organizationUuid": "org-1", - "currentUser": "signed-in" + "nestedContactPersonCount": 3 } ], [ "log.info", - "StackiqService: STEP 4B - Using original UUID format for OpenRegister", + "StackiqService: Added nested contact person username", { - "organizationUuid": "org-1" + "username": "a@a.nl", + "contactPersonData": { + "email": "a@a.nl" + } } ], [ - "log.info", - "StackiqService: STEP 4C - Getting OrganisationMapper from container", - [] + "container.get", + "OCA\\OpenRegister\\Service\\ObjectService" ], [ "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" + "OCA\\Stackiq\\Service\\SettingsService" + ], + [ + "settings.getVoorzieningenConfig" + ], + [ + "log.warning", + "StackiqService: Missing contactpersoon schema config for username extraction", + [] + ], + [ + "organisation.getUserIds", + [] + ], + [ + "mapper.addUserToOrganisation", + "org-1", + "a@a.nl" ], [ "log.info", - "StackiqService: STEP 4D - OrganisationMapper retrieved", + "StackiqService: Created organization in OpenRegister", { - "mapperClass": "Mock" + "organizationUuid": "org-1", + "openRegisterId": "org-1", + "contactPersons": 1 } + ] + ] + }, + "create: create, creator is a contact": { + "result": "entity:org-1", + "calls": [ + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "organisations.createOrganisation", + "Gemeente A", + "https://a.nl", + true, + "org-1" ], [ "log.info", "StackiqService: Processing nested contact persons", { "organizationUuid": "org-1", - "nestedContactPersonCount": 3 + "nestedContactPersonCount": 2 + } + ], + [ + "log.info", + "StackiqService: Added nested contact person username", + { + "username": "signed-in", + "contactPersonData": { + "email": "signed-in" + } } ], [ @@ -1083,60 +1083,50 @@ [] ], [ - "log.info", - "StackiqService: STEP 4E - Debug - UUID before createWithUuid", - { - "organizationUuid": "org-1", - "uuidLength": 5, - "uuidIsEmpty": false, - "name": "Gemeente A", - "description": "https://a.nl", - "owner": "signed-in", - "users": [ - "signed-in", - "a@a.nl" - ], - "contactPersonUsernames": [ - "a@a.nl" - ] - } + "organisation.getUserIds", + [] ], [ - "log.info", - "StackiqService: STEP 4F - Calling organisationMapper->createWithUuid()", - [] + "mapper.addUserToOrganisation", + "org-1", + "a@a.nl" ], [ "log.info", - "StackiqService: STEP 4F_DEBUG - Parameters for createWithUuid", + "StackiqService: Created organization in OpenRegister", { - "name": "Gemeente A", - "description": "https://a.nl", - "uuid": "org-1", - "owner": "signed-in", - "users": [ - "signed-in", - "a@a.nl" - ], - "isDefault": false, - "uuidLength": 5, - "uuidIsEmpty": false + "organizationUuid": "org-1", + "openRegisterId": "org-1", + "contactPersons": 2 } ] ] }, - "create: create, organisation service down": { + "create: create, no mapper": { "result": null, "calls": [ [ "container.get", "OCA\\OpenRegister\\Service\\OrganisationService" ], + [ + "container.get", + "OCA\\OpenRegister\\Service\\OrganisationService" + ], + [ + "container.get", + "OCA\\OpenRegister\\Db\\OrganisationMapper" + ], + [ + "log.error", + "Failed to get OrganisationMapper: no mapper", + [] + ], [ "log.error", "StackiqService: Error in public createOrganisationInOpenRegister", { - "error": "organisation service down", + "error": "OpenRegister organisations are not available", "objectData": { "id": "org-1", "name": "Gemeente A", @@ -1156,87 +1146,50 @@ ] ] }, - "create: create, anonymous, no mapper": { - "result": null, + "sync: sync, new, organisation service down": { + "result": false, "calls": [ - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], [ "log.info", - "StackiqService: STEP 1 - Starting createOrganisationInOpenRegister", - { - "organizationUuid": "org-1", - "name": "Gemeente A", - "mappedDataKeys": [ - "name", - "type", - "website", - "active", - "contactpersonen", - "participants" - ] - } - ], - [ - "log.info", - "StackiqService: STEP 2 - Checking user context", - { - "hasUserSession": true, - "currentUser": "null", - "isAnonymous": true - } - ], - [ - "log.info", - "StackiqService: STEP 3A - Anonymous path: No user, creating org directly via mapper", + "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", { - "organizationUuid": "org-1" + "objectId": 17, + "objectClass": "class@anonymous" } ], [ "log.info", - "StackiqService: STEP 3B - Using original UUID format for OpenRegister (anonymous)", + "StackiqService: SYNC_STEP_2 - Extracted organization data", { - "organizationUuid": "org-1" + "organizationUuid": "org-1", + "objectDataKeys": [ + "id", + "name", + "website", + "beoordeling", + "contactpersonen" + ], + "hasId": true } ], [ "log.info", - "StackiqService: STEP 3C - Getting OrganisationMapper from container", + "StackiqService: SYNC_STEP_3 - Getting OrganisationService", [] ], [ "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" + "OCA\\OpenRegister\\Service\\OrganisationService" ], [ "log.error", - "Failed to get OrganisationMapper: no mapper", + "Failed to get OrganisationService: organisation service down", [] ], [ "log.error", - "StackiqService: Error in public createOrganisationInOpenRegister", - { - "error": "OpenRegister OrganisationMapper is not available", - "objectData": { - "id": "org-1", - "name": "Gemeente A", - "website": "https://a.nl", - "beoordeling": "actief", - "contactpersonen": [ - { - "email": "a@a.nl" - }, - { - "naam": "zonder mail" - }, - "c-2" - ] - } - } + "StackiqService: SYNC_STEP_3 - OpenRegister OrganisationService not available", + [] ] ] } From 37745e86ea3704141bd1281f077c2fc804a3a751 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 19:19:13 +0100 Subject: [PATCH 10/10] refactor(organisations): delete StackiqService's unused organisation sync and creation Nothing calls StackiqService::handleNewOrganization, handleOrganizationUpdate, syncOrganizationWithOpenRegister or createOrganisationInOpenRegister: the background job creates organisations through OrganisatieService, and no class in lib/, src/ or appinfo/ references StackiqService at all. The 20 methods only those reach (1,800 lines) go, and with them the never-read OrganizationHandler, SymfonyEmailService, IUserSession, IUserManager and IGroupManager dependencies. That removes the creation path that called OpenRegister's createOrganisation() without a slug check, which would have handed an existing organisation with the same name to the caller, instead of fixing a path nothing uses. The golden test for that path, its stub additions and the stale tests/OrganizationSyncTest.php (outside every suite, against the old constructor) go too. Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Service/StackiqService.php | 2255 +---------------- tests/OrganizationSyncTest.php | 272 -- tests/Stubs/Db/Organisation.php | 48 +- tests/Stubs/Db/OrganisationMapper.php | 10 - tests/Stubs/Service/OrganisationService.php | 17 - ...ackiqServiceOrganisationSyncGoldenTest.php | 417 --- .../stackiqServiceOrganisationSync.json | 1196 --------- 7 files changed, 119 insertions(+), 4096 deletions(-) delete mode 100644 tests/OrganizationSyncTest.php delete mode 100644 tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php delete mode 100644 tests/Unit/Service/golden/stackiqServiceOrganisationSync.json diff --git a/lib/Service/StackiqService.php b/lib/Service/StackiqService.php index 73e9811e..d5de3bae 100644 --- a/lib/Service/StackiqService.php +++ b/lib/Service/StackiqService.php @@ -23,14 +23,9 @@ use OCA\Stackiq\Service\Stackiq\ContactPersonHandler; use OCA\Stackiq\Service\Stackiq\GroupHandler; use OCA\Stackiq\Service\Stackiq\HierarchyHandler; -use OCA\Stackiq\Service\Stackiq\OrganizationHandler; use OCP\App\IAppManager; -use OCP\IGroupManager; -use OCP\IUserManager; -use OCP\IUserSession; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; -use RuntimeException; /** * Service for handling software catalog operations. @@ -77,30 +72,20 @@ class StackiqService { /** * StackiqService constructor * - * @param OrganizationHandler $_organizationHandler Organization handler. * @param ContactPersonHandler $_contactPersonHandler Contact person handler. * @param GroupHandler $_groupHandler Group handler. * @param HierarchyHandler $_hierarchyHandler Hierarchy handler. - * @param SymfonyEmailService $_emailService Email service. * @param LoggerInterface $_logger Logger interface. * @param ContainerInterface $_container Container interface. * @param IAppManager $_appManager App manager interface. - * @param IUserSession $_userSession User session interface. - * @param IUserManager $_userManager User manager interface. - * @param IGroupManager $_groupManager Group manager interface. */ public function __construct( - private readonly OrganizationHandler $_organizationHandler, private readonly ContactPersonHandler $_contactPersonHandler, private readonly GroupHandler $_groupHandler, private readonly HierarchyHandler $_hierarchyHandler, - private readonly SymfonyEmailService $_emailService, private readonly LoggerInterface $_logger, private readonly ContainerInterface $_container, private readonly IAppManager $_appManager, - private readonly IUserSession $_userSession, - private readonly IUserManager $_userManager, - private readonly IGroupManager $_groupManager, ) { $this->appName = 'stackiq'; }//end __construct() @@ -123,24 +108,6 @@ private function getObjectService(): ?\OCA\OpenRegister\Contract\ObjectServiceIn } }//end getObjectService() - /** - * Gets the OrganisationService instance - * - * @return \OCA\OpenRegister\Service\OrganisationService|null - */ - private function getOrganisationService(): ?\OCA\OpenRegister\Service\OrganisationService { - if ($this->_appManager->isEnabledForUser(appId: 'openregister') === false) { - return null; - } - - try { - return $this->_container->get('OCA\\OpenRegister\\Service\\OrganisationService'); - } catch (\Exception $e) { - $this->_logger->error('Failed to get OrganisationService: ' . $e->getMessage()); - return null; - } - }//end getOrganisationService() - /** * Gets the OrganisationMapper instance * @@ -400,31 +367,6 @@ public function processContactpersoon(object $contactPersonObject, bool $isUpdat * @throws \Exception If processing fails */ - /** - * Processes organization without contactpersonen processing - * - * @param object $organizationObject The organization object to process. - * - * @return bool True if processing was successful. - * @throws \Exception If processing fails. - * - * @deprecated This method is disabled to prevent organization duplication. - * - * @spec openspec/specs/method-decomposition/spec.md - */ - public function processOrganization(object $organizationObject): bool { - // DISABLED: Organization processing is disabled to prevent duplication. - $this->_logger->info( - 'Organization processing is disabled to prevent duplication', - [ - 'organizationId' => $organizationObject->getId(), - ] - ); - - return false; - // Disabled: organization processing logic removed. - }//end processOrganization() - /** * Updates user groups based on contactpersoon data * @@ -472,345 +414,6 @@ public function getUserManager(string $username): ?string { return $this->_contactPersonHandler->getUserManager($username); }//end getUserManager() - /** - * Handles new organization creation - syncs with OpenRegister and processes organization - * - * @param object $organizationObject The new organization object - * - * @return void - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md - */ - public function handleNewOrganization(object $organizationObject): void { - try { - $this->_logger->info( - 'StackiqService: Handling new organization', - [ - 'objectId' => $organizationObject->getId(), - ] - ); - - // First, sync the organization with OpenRegister. - $syncResult = $this->syncOrganizationWithOpenRegister(organizationObject: $organizationObject); - - if ($syncResult === true) { - $this->_logger->info( - 'StackiqService: Successfully synced organization with OpenRegister', - [ - 'objectId' => $organizationObject->getId(), - ] - ); - - // Update organization references on objects to point to the newly created organization entity. - $this->updateOrganizationReferences(organizationObject: $organizationObject); - } else { - $this->_logger->warning( - 'StackiqService: Failed to sync organization with OpenRegister', - [ - 'objectId' => $organizationObject->getId(), - ] - ); - } - - // Process the organization (existing functionality) - this creates users. - $this->processOrganization(organizationObject: $organizationObject); - - // Add all admin group users to the organization. - $objectData = $organizationObject->getObject(); - $organizationUuid = $objectData['id'] ?? $organizationObject->getId(); - $this->addAdminGroupUsersToOrganization(organizationUuid: $organizationUuid); - - // Handle ownership assignment for anonymous user registrations AFTER user creation. - $this->handleOwnershipAssignment(organizationObject: $organizationObject); - - // Send welcome email for new organization. - $this->sendOrganizationWelcomeEmail(organizationObject: $organizationObject); - - // If organization is active, send activation email too. - $objectData = $organizationObject->getObject(); - $assessment = strtolower($objectData['beoordeling'] ?? ''); - - if ($assessment === 'actief') { - try { - $success = $this->_emailService->sendOrganizationActivationEmail($objectData); - $this->_logger->info( - 'Organization activation email sent', - [ - 'objectId' => $organizationObject->getId(), - 'success' => $success, - ] - ); - } catch (\Exception $e) { - $this->_logger->error( - 'Failed to send organization activation email: ' . $e->getMessage(), - [ - 'objectId' => $organizationObject->getId(), - 'exception' => $e, - ] - ); - } - } - - // Process nested contact persons and add their users to the organization entity. - $contactpersonen = $objectData['contactpersonen'] ?? []; - if (empty($contactpersonen) === false) { - $this->_logger->info( - 'StackiqService: Processing nested contact persons', - [ - 'objectId' => $organizationObject->getId(), - 'contactPersonCount' => count($contactpersonen), - ] - ); - - $organizationUuid = $objectData['id'] ?? $organizationObject->getId(); - $objectService = $this->getObjectService(); - - if (empty($objectService) === false) { - $settingsService = $this->_container->get('OCA\Stackiq\Service\SettingsService'); - $voorzieningenConfig = $settingsService->getVoorzieningenConfig(); - $contactSchemaId = $voorzieningenConfig['contactpersoon_schema'] ?? null; - - if ($contactSchemaId === null) { - $this->_logger->warning('StackiqService: Missing contactpersoon schema configuration'); - return; - } - - $organisationMapper = $this->getOrganisationMapper(); - if ($organisationMapper === null) { - $this->_logger->warning( - 'StackiqService: OpenRegister OrganisationMapper not available, skipping contact person membership' - ); - return; - } - - $organisation = $organisationMapper->findByUuid($organizationUuid); - - if (empty($organisation) === false) { - $currentUsers = $organisation->getUsers() ?? []; - $addedUsers = []; - - foreach ($contactpersonen as $contactPersonId) { - try { - $contactPersonObject = $objectService->find($contactPersonId); - $contactData = $contactPersonObject->getObject(); - $email = $contactData['email'] ?? null; - - if ($email !== false && in_array($email, $currentUsers) === false) { - $currentUsers[] = $email; - $addedUsers[] = $email; - - $this->_logger->info( - 'StackiqService: Added nested contact person user to organization', - [ - 'objectId' => $organizationObject->getId(), - 'contactPersonId' => $contactPersonId, - 'username' => $email, - 'organizationUuid' => $organizationUuid, - ] - ); - } - } catch (\Exception $e) { - $this->_logger->warning( - 'StackiqService: Failed to process nested contact person', - [ - 'objectId' => $organizationObject->getId(), - 'contactPersonId' => $contactPersonId, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end foreach - - if (empty($addedUsers) === false) { - $organisation->setUsers($currentUsers); - $organisationMapper->save($organisation); - - $this->_logger->info( - 'StackiqService: Updated org with nested contact person users', - [ - 'objectId' => $organizationObject->getId(), - 'organizationUuid' => $organizationUuid, - 'addedUsers' => $addedUsers, - 'totalUsers' => count($currentUsers), - ] - ); - } - }//end if - }//end if - }//end if - - // Final synchronization: ensure all contact persons associated with this organization are in the users array. - // This handles cases where contact persons were created separately and not as nested objects. - $objectData = $organizationObject->getObject(); - $organizationUuid = $objectData['id'] ?? $organizationObject->getId(); - $this->syncContactPersonUsernamesWithOrganization(organizationUuid: $organizationUuid); - - $this->_logger->info( - 'StackiqService: Completed final contact person synchronization for new organization', - [ - 'objectId' => $organizationObject->getId(), - 'organizationUuid' => $organizationUuid, - ] - ); - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Failed to handle new organization: ' . $e->getMessage(), - [ - 'objectId' => $organizationObject->getId(), - 'exception' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => $e->getTraceAsString(), - ] - ); - }//end try - }//end handleNewOrganization() - - /** - * Handles organization updates - syncs with OpenRegister and manages user status based on organization status - * - * @param object $organizationObject The updated organization object - * @param object $oldOrganizationObject The previous organization object - * - * @return void - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md - */ - public function handleOrganizationUpdate(object $organizationObject, object $oldOrganizationObject): void { - try { - $this->_logger->info( - 'StackiqService: Handling organization update', - [ - 'objectId' => $organizationObject->getId(), - ] - ); - - $newData = $organizationObject->getObject(); - $oldData = $oldOrganizationObject->getObject(); - - // Check both 'beoordeling' and 'status' fields (different schemas use different field names). - $newAssessment = strtolower($newData['beoordeling'] ?? $newData['status'] ?? ''); - $oldAssessment = strtolower($oldData['beoordeling'] ?? $oldData['status'] ?? ''); - - // Sync the organization with OpenRegister. - $syncResult = $this->syncOrganizationWithOpenRegister(organizationObject: $organizationObject); - - if ($syncResult === true) { - $this->_logger->info( - 'StackiqService: Successfully synced organization with OpenRegister', - [ - 'objectId' => $organizationObject->getId(), - ] - ); - } else { - $this->_logger->warning( - 'StackiqService: Failed to sync organization with OpenRegister', - [ - 'objectId' => $organizationObject->getId(), - ] - ); - } - - // Add all admin group users to the organization (ensure they're always included). - $organizationUuid = $newData['id'] ?? $organizationObject->getId(); - $this->addAdminGroupUsersToOrganization(organizationUuid: $organizationUuid); - - // Check if organization status changed to active. - if ($newAssessment === 'actief') { - $becameActive = ($oldAssessment !== 'actief'); - - $activeMessage = 'Organization is active'; - if ($becameActive === true) { - $activeMessage = 'Organization became active'; - } - - $this->_logger->info( - $activeMessage, - [ - 'organizationId' => $organizationObject->getId(), - 'oldBeoordeling' => $oldAssessment, - 'newBeoordeling' => $newAssessment, - 'becameActive' => $becameActive, - ] - ); - - if ($becameActive === true) { - $organizationUuid = $newData['id'] ?? $organizationObject->getId(); - - $this->_logger->info( - 'StackiqService: Organization became active - creating users from contactpersonen', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - // Process the organization to create users from contactpersonen. - // This is crucial when an organization is activated for the first time. - // and contactpersonen were added before activation. - $this->processOrganization(organizationObject: $organizationObject); - - // Activate Stackiq-specific users in this organization. - $this->activateStackiqUsersForOrganization(organizationUuid: $organizationUuid); - - // Send activation email. - try { - $success = $this->_emailService->sendOrganizationActivationEmail($newData); - $this->_logger->info( - 'Organization activation email sent', - [ - 'objectId' => $organizationObject->getId(), - 'success' => $success, - ] - ); - } catch (\Exception $e) { - $this->_logger->error( - 'Failed to send organization activation email: ' . $e->getMessage(), - [ - 'objectId' => $organizationObject->getId(), - 'exception' => $e, - ] - ); - } - }//end if - }//end if - - // Check if organization status changed to inactive. - if ($newAssessment === 'inactief' || $newAssessment === 'deactief') { - $becameInactive = ($oldAssessment === 'actief'); - - $inactiveMessage = 'Organization is inactive'; - if ($becameInactive === true) { - $inactiveMessage = 'Organization became inactive'; - } - - $this->_logger->info( - $inactiveMessage, - [ - 'organizationId' => $organizationObject->getId(), - 'oldBeoordeling' => $oldAssessment, - 'newBeoordeling' => $newAssessment, - 'becameInactive' => $becameInactive, - ] - ); - - if ($becameInactive === true) { - // Deactivate Stackiq-specific users in this organization. - $organizationUuid = $newData['id'] ?? $organizationObject->getId(); - $this->deactivateStackiqUsersForOrganization(organizationUuid: $organizationUuid); - } - }//end if - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Failed to handle organization update: ' . $e->getMessage(), - [ - 'objectId' => $organizationObject->getId(), - 'exception' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => $e->getTraceAsString(), - ] - ); - }//end try - }//end handleOrganizationUpdate() - /** * Activates contactpersonen users for an organization. * @@ -833,29 +436,6 @@ private function activateContactpersonenForOrganization(string $organizationId): // Disabled: organization contactpersonen activation logic removed. }//end activateContactpersonenForOrganization() - /** - * Sends welcome email to organization. - * - * @param object $organizationObject The organization object. - * - * @return void - * - * @deprecated This method is disabled to prevent organization duplication. - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md - */ - public function sendOrganizationWelcomeEmail(object $organizationObject): void { - // DISABLED: Organization handling is disabled to prevent duplication. - $this->_logger->info( - 'Organization welcome email sending is disabled to prevent duplication', - [ - 'organizationId' => $organizationObject->getId(), - ] - ); - - return; - // Disabled: organization welcome email logic removed. - }//end sendOrganizationWelcomeEmail() - /** * Handles new contact creation * @@ -1275,561 +855,35 @@ public function handleOrganizationDeletion(object $organizationObject): void { }//end handleOrganizationDeletion() /** - * Syncs organization data with OpenRegister + * Activates all users in an organization when the organization becomes active * - * @param object $organizationObject The organization object to sync + * @param string $organizationUuid The organization UUID * - * @return bool True if sync was successful - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md + * @return void */ - public function syncOrganizationWithOpenRegister(object $organizationObject): bool { + private function activateUsersForOrganization(string $organizationUuid): void { try { $this->_logger->info( - 'StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister', - [ - 'objectId' => $organizationObject->getId(), - 'objectClass' => get_class($organizationObject), - ] - ); - - $objectData = $organizationObject->getObject(); - $organizationUuid = $objectData['id'] ?? $organizationObject->getId(); - - $this->_logger->info( - 'StackiqService: SYNC_STEP_2 - Extracted organization data', + 'StackiqService: Activating users for organization', [ 'organizationUuid' => $organizationUuid, - 'objectDataKeys' => array_keys($objectData), - 'hasId' => isset($objectData['id']) === true, ] ); - // Get OpenRegister OrganisationService for proper organization entity management. - $this->_logger->info('StackiqService: SYNC_STEP_3 - Getting OrganisationService'); - $organisationService = $this->getOrganisationService(); - if ($organisationService === null) { - $this->_logger->error( - 'StackiqService: SYNC_STEP_3 - OpenRegister OrganisationService not available' - ); - return false; + $objectService = $this->getObjectService(); + if ($objectService === null) { + $this->_logger->error('StackiqService: OpenRegister ObjectService not available'); + return; } - $this->_logger->info( - 'StackiqService: SYNC_STEP_3 - OrganisationService retrieved', - [ - 'serviceClass' => get_class($organisationService), - ] - ); + // Get all contactpersonen for this organization. + $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'contactPerson', logContext: 'contactpersonen'); + if ($ctx === null) { + return; + } - $this->_logger->info( - 'StackiqService: SYNC_STEP_4 - OpenRegister configuration', - [ - 'organizationUuid' => $organizationUuid, - 'organizationName' => $objectData['name'] ?? 'Unknown', - ] - ); - - // Check if organization already exists in OpenRegister. - $this->_logger->info('StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister'); - try { - $this->_logger->info('StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup'); - $organisationMapper = $this->getOrganisationMapper(); - if ($organisationMapper === null) { - $this->_logger->error( - 'StackiqService: OpenRegister OrganisationMapper not available, cannot sync organization' - ); - return false; - } - - $this->_logger->info( - 'StackiqService: SYNC_STEP_5B - Calling findByUuid', - [ - 'uuid' => $organizationUuid, - ] - ); - $existingOrganisation = $organisationMapper->findByUuid($organizationUuid); - - // Organization exists - update it. - $this->_logger->info( - 'StackiqService: SYNC_STEP_6 - Organization exists in OpenRegister, updating', - [ - 'organizationId' => $organizationUuid, - 'existingOrganisationClass' => get_class($existingOrganisation), - ] - ); - - // Map status from Stackiq to OpenRegister. - $this->_logger->info('StackiqService: SYNC_STEP_7 - Mapping organization data'); - $mappedData = $this->mapOrganizationDataForOpenRegister(objectData: $objectData); - - // Update the organization using OrganisationService. - $updatedOrganisation = $this->updateOrganisationInOpenRegister( - existingOrganisation: $existingOrganisation, - mappedData: $mappedData - ); - - $this->_logger->info( - 'StackiqService: Successfully updated organization in OpenRegister', - [ - 'organizationId' => $organizationUuid, - 'openRegisterId' => $updatedOrganisation->getUuid(), - ] - ); - - return true; - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - // Organization doesn't exist - create it. - $this->_logger->info( - 'StackiqService: SYNC_STEP_8 - Organization not found in OpenRegister, creating', - [ - 'organizationId' => $organizationUuid, - 'exception' => $e->getMessage(), - ] - ); - - // Map status from Stackiq to OpenRegister. - $this->_logger->info('StackiqService: SYNC_STEP_9 - Mapping organization data for creation'); - $mappedData = $this->mapOrganizationDataForOpenRegister(objectData: $objectData); - - // Create the organization using OrganisationService. - $this->_logger->info('StackiqService: SYNC_STEP_10 - Calling createOrganisationInOpenRegister'); - $createdOrganisation = $this->createOrganisationInOpenRegisterInternal( - mappedData: $mappedData, - organizationUuid: $organizationUuid - ); - - $this->_logger->info( - 'StackiqService: SYNC_STEP_11 - Successfully created organization in OpenRegister', - [ - 'organizationId' => $organizationUuid, - 'openRegisterId' => $createdOrganisation->getUuid(), - 'createdOrganisationClass' => get_class($createdOrganisation), - ] - ); - - return true; - }//end try - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Failed to sync organization with OpenRegister: ' . $e->getMessage(), - [ - 'objectId' => $organizationObject->getId(), - 'exception' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => $e->getTraceAsString(), - ] - ); - return false; - }//end try - }//end syncOrganizationWithOpenRegister() - - /** - * Public wrapper for creating organization in OpenRegister (used by background job) - * - * @param array $objectData The organization object data - * - * @return object|null The created organisation entity or null on failure - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md - */ - public function createOrganisationInOpenRegister(array $objectData): ?object { - try { - $organizationUuid = $objectData['id'] ?? null; - if ($organizationUuid === null) { - $this->_logger->error('StackiqService: No organization UUID provided for creation'); - return null; - } - - // Map the data. - $mappedData = [ - 'name' => $objectData['name'] ?? 'Unknown', - 'type' => $objectData['type'] ?? '', - 'website' => $objectData['website'] ?? '', - 'active' => $this->mapStatus(status: $objectData['beoordeling'] ?? 'actief'), - 'contactpersonen' => $objectData['contactpersonen'] ?? [], - 'participants' => $objectData['participants'] ?? [], - ]; - - // Fails early when OpenRegister's organisation service is not available. - $this->_container->get('OCA\OpenRegister\Service\OrganisationService'); - - return $this->createOrganisationInOpenRegisterInternal( - mappedData: $mappedData, - organizationUuid: $organizationUuid - ); - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Error in public createOrganisationInOpenRegister', - [ - 'error' => $e->getMessage(), - 'objectData' => $objectData, - ] - ); - return null; - }//end try - }//end createOrganisationInOpenRegister() - - /** - * Map status from Software Catalog to OpenRegister format - * - * @param string $status The status from Software Catalog - * - * @return bool The mapped active status for OpenRegister - */ - private function mapStatus(string $status): bool { - switch (strtolower($status)) { - case 'actief': - return true; - case 'inactief': - return false; - default: - return true; - // Default to active. - } - }//end mapStatus() - - /** - * Creates an organization in OpenRegister through its OrganisationService - * - * The signed-in user creates it and becomes its owner and a member; the - * organisation's contact persons are added as members. Without a signed-in - * user nothing is created: no rule says who would own it. - * - * @param array $mappedData The mapped organization data - * @param string $organizationUuid The organization UUID to use - * - * @return \OCA\OpenRegister\Db\Organisation The created organization - * - * @throws RuntimeException When no user is signed in, or OpenRegister is not available - */ - private function createOrganisationInOpenRegisterInternal(array $mappedData, string $organizationUuid): \OCA\OpenRegister\Db\Organisation { - if ($this->_userSession->getUser() === null) { - throw new RuntimeException('An OpenRegister organisation is only created for a signed-in user'); - } - - $organisationService = $this->getOrganisationService(); - $organisationMapper = $this->getOrganisationMapper(); - if ($organisationService === null || $organisationMapper === null) { - throw new RuntimeException('OpenRegister organisations are not available'); - } - - $organisation = $organisationService->createOrganisation( - name: $mappedData['name'] ?? 'Unknown Organization', - description: $mappedData['website'] ?? '', - addCurrentUser: true, - uuid: $organizationUuid - ); - - $contactPersonUsernames = $this->collectContactPersonUsernames( - organizationUuid: $organizationUuid, - objectData: $mappedData - ); - foreach (array_diff(array_unique($contactPersonUsernames), $organisation->getUserIds()) as $username) { - $organisation = $organisationMapper->addUserToOrganisation($organisation->getUuid(), $username); - } - - $this->_logger->info( - 'StackiqService: Created organization in OpenRegister', - [ - 'organizationUuid' => $organizationUuid, - 'openRegisterId' => $organisation->getUuid(), - 'contactPersons' => count($contactPersonUsernames), - ] - ); - - return $organisation; - }//end createOrganisationInOpenRegisterInternal() - - /** - * Updates an organization in OpenRegister using OrganisationService - * - * @param \OCA\OpenRegister\Db\Organisation $existingOrganisation The existing organization - * @param array $mappedData The mapped organization data - * - * @return \OCA\OpenRegister\Db\Organisation The updated organization - */ - private function updateOrganisationInOpenRegister( - \OCA\OpenRegister\Db\Organisation $existingOrganisation, - array $mappedData, - ): \OCA\OpenRegister\Db\Organisation { - $this->_logger->info( - 'StackiqService: Updating organization in OpenRegister', - [ - 'organizationUuid' => $existingOrganisation->getUuid(), - 'name' => $mappedData['name'] ?? 'Unknown', - ] - ); - - // Update organization fields (only those that exist on the Organisation entity). - if (isset($mappedData['name']) === true) { - $existingOrganisation->setName($mappedData['name']); - } - - if (isset($mappedData['description']) === true) { - $existingOrganisation->setDescription($mappedData['description']); - } - - // Note: OpenRegister Organisation entity doesn't have status or type fields. - // These are managed in the Stackiq object, not in the OpenRegister organisation. - // Save the updated organization. - $organisationMapper = $this->getOrganisationMapper(); - if ($organisationMapper === null) { - // Non-nullable return type; the caller already holds an OpenRegister - // OrganisationService, so this escapes as the container lookup used to. - throw new RuntimeException('OpenRegister OrganisationMapper is not available'); - } - - $updatedOrganisation = $organisationMapper->save($existingOrganisation); - - $this->_logger->info( - 'StackiqService: Successfully updated organization in OpenRegister', - [ - 'organizationUuid' => $existingOrganisation->getUuid(), - 'openRegisterId' => $updatedOrganisation->getUuid(), - ] - ); - - return $updatedOrganisation; - }//end updateOrganisationInOpenRegister() - - /** - * Collects all contact person usernames associated with an organization - * - * @param string $organizationUuid The organization UUID - * @param array $objectData The organization object data (for nested contact persons) - * - * @return array Array of usernames - */ - private function collectContactPersonUsernames(string $organizationUuid, array $objectData = []): array { - $usernames = []; - - // Focus on nested contact persons in the organization object data. - // These are available immediately when the organization is created. - $nestedContactPersons = $objectData['contactpersonen'] ?? []; - $this->_logger->info( - 'StackiqService: Processing nested contact persons', - [ - 'organizationUuid' => $organizationUuid, - 'nestedContactPersonCount' => count($nestedContactPersons), - ] - ); - - foreach ($nestedContactPersons as $contactPerson) { - if (is_array($contactPerson) === true && isset($contactPerson['email']) === true) { - $usernames[] = $contactPerson['email']; - $this->_logger->info( - 'StackiqService: Added nested contact person username', - [ - 'username' => $contactPerson['email'], - 'contactPersonData' => $contactPerson, - ] - ); - } - } - - // Also try to find existing contact persons by their organisatie field. - // This is useful for updates or when contact persons were created separately. - $objectService = $this->getObjectService(); - if (empty($objectService) === false) { - $settingsService = $this->_container->get('OCA\Stackiq\Service\SettingsService'); - $voorzieningenConfig = $settingsService->getVoorzieningenConfig(); - $contactSchemaId = $voorzieningenConfig['contactpersoon_schema'] ?? null; - - if ($contactSchemaId === null) { - $this->_logger->warning( - 'StackiqService: Missing contactpersoon schema config for username extraction' - ); - return $usernames; - } - - try { - // Try multiple approaches to find contact persons. - $contactPersons = []; - - // Approach 1: Find by organisatie field. - try { - $contactPersons = $objectService->findAll( - [ - 'filters' => [ - 'register' => $objectData['register'] ?? '6', - 'schema' => $contactSchemaId, - 'organization' => $organizationUuid, - ], - ] - ); - } catch (\Exception $e) { - $this->_logger->info( - 'StackiqService: Approach 1 failed, trying approach 2', - [ - 'organizationUuid' => $organizationUuid, - 'error' => $e->getMessage(), - ] - ); - } - - // Approach 2: If approach 1 fails, try to find all contact persons and filter by organisatie. - if (empty($contactPersons) === true) { - try { - $allContactPersons = $objectService->findAll( - [ - 'filters' => [ - 'register' => $objectData['register'] ?? '6', - 'schema' => $contactSchemaId, - ], - ] - ); - - foreach ($allContactPersons as $contactPerson) { - $contactData = $contactPerson->getObject(); - $contactOrganisation = $contactData['organization'] ?? null; - if ($contactOrganisation === $organizationUuid) { - $contactPersons[] = $contactPerson; - } - } - } catch (\Exception $e) { - $this->_logger->info( - 'StackiqService: Approach 2 also failed', - [ - 'organizationUuid' => $organizationUuid, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end if - - $this->_logger->info( - 'StackiqService: Found existing contact persons for organization', - [ - 'organizationUuid' => $organizationUuid, - 'contactPersonCount' => count($contactPersons), - 'contactPersonIds' => array_map( - function ($cp) { - return $cp->getId(); - }, - $contactPersons - ), - ] - ); - - foreach ($contactPersons as $contactPerson) { - $contactData = $contactPerson->getObject(); - $email = $contactData['email'] ?? null; - if (empty($email) === false) { - $usernames[] = $email; - $this->_logger->info( - 'StackiqService: Added existing contact person username', - [ - 'username' => $email, - 'contactPersonId' => $contactPerson->getId(), - ] - ); - } - } - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Error collecting existing contact person usernames', - [ - 'organizationUuid' => $organizationUuid, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end if - - // Remove duplicates and return. - $uniqueUsernames = array_unique($usernames); - $this->_logger->info( - 'StackiqService: Collected contact person usernames', - [ - 'organizationUuid' => $organizationUuid, - 'totalUsernames' => count($uniqueUsernames), - 'usernames' => $uniqueUsernames, - ] - ); - - return $uniqueUsernames; - }//end collectContactPersonUsernames() - - /** - * Maps organization data from Stackiq format to OpenRegister format - * - * @param array $objectData The organization data from Stackiq - * - * @return array The mapped data for OpenRegister - */ - private function mapOrganizationDataForOpenRegister(array $objectData): array { - $mappedData = [ - 'name' => $objectData['name'] ?? '', - 'type' => $objectData['type'] ?? '', - 'website' => $objectData['website'] ?? '', - 'active' => false, - // Default to inactive for new organizations. - 'contactpersonen' => [], - 'participants' => [], - ]; - - // Map status from Stackiq to OpenRegister. - $assessment = strtolower($objectData['beoordeling'] ?? ''); - if ($assessment === 'actief') { - $mappedData['active'] = true; - } elseif ($assessment === 'inactief' || $assessment === 'deactief') { - $mappedData['active'] = false; - } - - // Map other fields if they exist. - if (isset($objectData['adres']) === true) { - $mappedData['adres'] = $objectData['adres']; - } - - if (isset($objectData['postcode']) === true) { - $mappedData['postcode'] = $objectData['postcode']; - } - - if (isset($objectData['plaats']) === true) { - $mappedData['plaats'] = $objectData['plaats']; - } - - if (isset($objectData['telefoon']) === true) { - $mappedData['telefoon'] = $objectData['telefoon']; - } - - if (isset($objectData['email']) === true) { - $mappedData['email'] = $objectData['email']; - } - - return $mappedData; - }//end mapOrganizationDataForOpenRegister() - - /** - * Activates all users in an organization when the organization becomes active - * - * @param string $organizationUuid The organization UUID - * - * @return void - */ - private function activateUsersForOrganization(string $organizationUuid): void { - try { - $this->_logger->info( - 'StackiqService: Activating users for organization', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - $objectService = $this->getObjectService(); - if ($objectService === null) { - $this->_logger->error('StackiqService: OpenRegister ObjectService not available'); - return; - } - - // Get all contactpersonen for this organization. - $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'contactPerson', logContext: 'contactpersonen'); - if ($ctx === null) { - return; - } - - $registerId = $ctx['registerId']; - $contactPersonSchemaId = $ctx['schemaId']; + $registerId = $ctx['registerId']; + $contactPersonSchemaId = $ctx['schemaId']; // The findAll() signature is ($config, bool $_rbac, bool $_multitenancy). // Register and schema belong inside the config's filters, as the @@ -1985,521 +1039,14 @@ private function deactivateUsersForOrganization(string $organizationUuid): void }//end deactivateUsersForOrganization() /** - * Activates Stackiq-specific users for an organization - * Only affects users from contactpersoon objects, not admin group users + * Checks if a contactpersoon username is in the organization's users list * - * @param string $organizationUuid The organization UUID + * @param object $contactPersonObject The contactpersoon object * - * @return void + * @return bool True if the user should be added to the organization + * @spec openspec/specs/softwarecatalogue-orchestration/spec.md */ - private function activateStackiqUsersForOrganization(string $organizationUuid): void { - try { - $this->_logger->info( - 'StackiqService: Activating Stackiq users for organization', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - // Get Stackiq-specific users (from contactpersonen). - $softwareCatalogUsers = $this->getStackiqUsersForOrganization(organizationUuid: $organizationUuid); - - if (empty($softwareCatalogUsers) === true) { - $this->_logger->info( - 'StackiqService: No Stackiq users found for organization', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - return; - } - - $this->_logger->info( - 'StackiqService: Found Stackiq users to activate', - [ - 'organizationUuid' => $organizationUuid, - 'userCount' => count($softwareCatalogUsers), - 'users' => $softwareCatalogUsers, - ] - ); - - // Get the user manager. - $userManager = $this->_userManager; - $activatedUsers = []; - $failedUsers = []; - - foreach ($softwareCatalogUsers as $username) { - try { - $user = $userManager->get($username); - if ($user !== null && $user->isEnabled() === false) { - $user->setEnabled(true); - $activatedUsers[] = $username; - $this->_logger->debug( - 'StackiqService: Activated Stackiq user', - [ - 'organizationUuid' => $organizationUuid, - 'username' => $username, - ] - ); - } elseif ($user !== null && $user->isEnabled() === true) { - $this->_logger->debug( - 'StackiqService: Stackiq user already active', - [ - 'organizationUuid' => $organizationUuid, - 'username' => $username, - ] - ); - } else { - $failedUsers[] = $username; - $this->_logger->warning( - 'StackiqService: Stackiq user not found', - [ - 'organizationUuid' => $organizationUuid, - 'username' => $username, - ] - ); - }//end if - } catch (\Exception $e) { - $failedUsers[] = $username; - $this->_logger->error( - 'StackiqService: Failed to activate Stackiq user', - [ - 'organizationUuid' => $organizationUuid, - 'username' => $username, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end foreach - - $this->_logger->info( - 'StackiqService: Stackiq user activation complete', - [ - 'organizationUuid' => $organizationUuid, - 'activatedUsers' => $activatedUsers, - 'failedUsers' => $failedUsers, - 'totalProcessed' => count($softwareCatalogUsers), - ] - ); - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Error activating Stackiq users for organization', - [ - 'organizationUuid' => $organizationUuid, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end activateStackiqUsersForOrganization() - - /** - * Deactivates Stackiq-specific users for an organization - * Only affects users from contactpersoon objects, not admin group users - * - * @param string $organizationUuid The organization UUID - * - * @return void - */ - private function deactivateStackiqUsersForOrganization(string $organizationUuid): void { - try { - $this->_logger->info( - 'StackiqService: Deactivating Stackiq users for organization', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - // Get Stackiq-specific users (from contactpersonen). - $softwareCatalogUsers = $this->getStackiqUsersForOrganization(organizationUuid: $organizationUuid); - - if (empty($softwareCatalogUsers) === true) { - $this->_logger->info( - 'StackiqService: No Stackiq users found for organization', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - return; - } - - $this->_logger->info( - 'StackiqService: Found Stackiq users to deactivate', - [ - 'organizationUuid' => $organizationUuid, - 'userCount' => count($softwareCatalogUsers), - 'users' => $softwareCatalogUsers, - ] - ); - - // Get the user manager. - $userManager = $this->_userManager; - $deactivatedUsers = []; - $failedUsers = []; - - foreach ($softwareCatalogUsers as $username) { - try { - $user = $userManager->get($username); - if ($user !== null && $user->isEnabled() === true) { - $user->setEnabled(false); - $deactivatedUsers[] = $username; - $this->_logger->debug( - 'StackiqService: Deactivated Stackiq user', - [ - 'organizationUuid' => $organizationUuid, - 'username' => $username, - ] - ); - } elseif ($user !== null && $user->isEnabled() === false) { - $this->_logger->debug( - 'StackiqService: Stackiq user already inactive', - [ - 'organizationUuid' => $organizationUuid, - 'username' => $username, - ] - ); - } else { - $failedUsers[] = $username; - $this->_logger->warning( - 'StackiqService: Stackiq user not found', - [ - 'organizationUuid' => $organizationUuid, - 'username' => $username, - ] - ); - }//end if - } catch (\Exception $e) { - $failedUsers[] = $username; - $this->_logger->error( - 'StackiqService: Failed to deactivate Stackiq user', - [ - 'organizationUuid' => $organizationUuid, - 'username' => $username, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end foreach - - $this->_logger->info( - 'StackiqService: Stackiq user deactivation complete', - [ - 'organizationUuid' => $organizationUuid, - 'deactivatedUsers' => $deactivatedUsers, - 'failedUsers' => $failedUsers, - 'totalProcessed' => count($softwareCatalogUsers), - ] - ); - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Error deactivating Stackiq users for organization', - [ - 'organizationUuid' => $organizationUuid, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end deactivateStackiqUsersForOrganization() - - /** - * Gets Stackiq-specific users for an organization - * These are users from contactpersoon objects, excluding admin group users - * - * @param string $organizationUuid The organization UUID - * - * @return array Array of usernames - */ - private function getStackiqUsersForOrganization(string $organizationUuid): array { - try { - $this->_logger->debug( - 'StackiqService: Getting Stackiq users for organization', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - // Get the object service to find contactpersonen. - $objectService = $this->getObjectService(); - if ($objectService === null) { - $this->_logger->error( - 'StackiqService: ObjectService not available for getting Stackiq users' - ); - return []; - } - - // Find all contactpersonen for this organization. - $contactpersonen = $objectService->findAll( - [ - 'filters' => [ - 'register' => 6, - // Voorzieningen register. - 'schema' => 38, - // Contactpersoon schema. - ], - ] - ); - - $softwareCatalogUsers = []; - $adminGroupUsers = $this->getAdminGroupUsernames(); - - foreach ($contactpersonen as $contactPersonObject) { - $contactData = $contactPersonObject->getObject(); - $contactOrganisation = $contactData['organization'] ?? null; - - // Check if this contactpersoon belongs to our organization. - if ($contactOrganisation === $organizationUuid) { - // Extract username from contactpersoon object data. - $contactData = $contactPersonObject->getObject(); - $username = $contactData['username'] ?? null; - - if ($username !== false && in_array($username, $adminGroupUsers) === false) { - $softwareCatalogUsers[] = $username; - $this->_logger->debug( - 'StackiqService: Found Stackiq user', - [ - 'organizationUuid' => $organizationUuid, - 'username' => $username, - 'contactpersoonId' => $contactPersonObject->getId(), - ] - ); - } - } - }//end foreach - - $this->_logger->info( - 'StackiqService: Found Stackiq users for organization', - [ - 'organizationUuid' => $organizationUuid, - 'userCount' => count($softwareCatalogUsers), - 'users' => $softwareCatalogUsers, - ] - ); - - return $softwareCatalogUsers; - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Error getting Stackiq users for organization', - [ - 'organizationUuid' => $organizationUuid, - 'error' => $e->getMessage(), - ] - ); - return []; - }//end try - }//end getStackiqUsersForOrganization() - - /** - * Gets all usernames from the admin group - * - * @return array Array of admin usernames - */ - private function getAdminGroupUsernames(): array { - try { - $groupManager = $this->_groupManager; - $adminGroup = $groupManager->get('admin'); - - if ($adminGroup === null) { - $this->_logger->warning('StackiqService: Admin group not found'); - return []; - } - - $adminUsers = $adminGroup->getUsers(); - $adminUsernames = []; - - foreach ($adminUsers as $adminUser) { - $adminUsernames[] = $adminUser->getUID(); - } - - $this->_logger->debug( - 'StackiqService: Found admin group users', - [ - 'adminUserCount' => count($adminUsernames), - 'adminUsers' => $adminUsernames, - ] - ); - - return $adminUsernames; - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Error getting admin group usernames', - [ - 'error' => $e->getMessage(), - ] - ); - return []; - }//end try - }//end getAdminGroupUsernames() - - /** - * Adds all users from the admin group to the organization entity - * - * @param string $organizationUuid The organization UUID - * - * @return void - */ - private function addAdminGroupUsersToOrganization(string $organizationUuid): void { - try { - $this->_logger->info( - 'StackiqService: Adding admin group users to organization entity', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - // Get the group manager to access admin group users. - $groupManager = $this->_groupManager; - $adminGroup = $groupManager->get('admin'); - - if ($adminGroup === null) { - $this->_logger->warning('StackiqService: Admin group not found'); - return; - } - - $adminUsers = $adminGroup->getUsers(); - $this->_logger->info( - 'StackiqService: Found admin group users', - [ - 'organizationUuid' => $organizationUuid, - 'adminUserCount' => count($adminUsers), - ] - ); - - // Get the organization entity (not object) to update its users list. - $organisationMapper = $this->_container->get('OCA\\OpenRegister\\Db\\OrganisationMapper'); - if ($organisationMapper === null) { - $this->_logger->error('StackiqService: OrganisationMapper not available for adding admin users'); - return; - } - - // Find the organization entity by UUID. - $this->_logger->info( - 'StackiqService: Searching for organization entity', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - - try { - $targetOrganisation = $organisationMapper->findByUuid($organizationUuid); - - $this->_logger->info( - 'StackiqService: Found target organization entity', - [ - 'organizationUuid' => $organizationUuid, - 'entityId' => $targetOrganisation->getId(), - ] - ); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - $this->_logger->warning( - 'StackiqService: Organization entity not found for adding admin users', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - return; - } - - // Get current users list from the entity. - $currentUsers = $targetOrganisation->getUsers() ?? []; - - $this->_logger->info( - 'StackiqService: Current organization entity users', - [ - 'organizationUuid' => $organizationUuid, - 'currentUsers' => $currentUsers, - 'currentUserCount' => count($currentUsers), - ] - ); - - // Add admin users to the list. - $updatedUsers = $currentUsers; - $addedUsers = []; - foreach ($adminUsers as $adminUser) { - $adminUsername = $adminUser->getUID(); - if (in_array($adminUsername, $updatedUsers) === false) { - $updatedUsers[] = $adminUsername; - $addedUsers[] = $adminUsername; - $this->_logger->debug( - 'StackiqService: Added admin user to organization entity', - [ - 'organizationUuid' => $organizationUuid, - 'adminUsername' => $adminUsername, - ] - ); - } - } - - $this->_logger->info( - 'StackiqService: Admin users processing complete', - [ - 'organizationUuid' => $organizationUuid, - 'addedUsers' => $addedUsers, - 'totalUsersAfterUpdate' => count($updatedUsers), - ] - ); - - // Update the organization entity with the new users list. - if (count($updatedUsers) > count($currentUsers)) { - $this->_logger->info( - 'StackiqService: Updating organization entity with new users', - [ - 'organizationUuid' => $organizationUuid, - 'entityId' => $targetOrganisation->getId(), - 'usersToAdd' => count($updatedUsers) - count($currentUsers), - ] - ); - - // Set the updated users list on the entity. - $targetOrganisation->setUsers($updatedUsers); - - $this->_logger->info( - 'StackiqService: Saving updated organization entity', - [ - 'organizationUuid' => $organizationUuid, - 'entityId' => $targetOrganisation->getId(), - 'newUserCount' => count($updatedUsers), - ] - ); - - // Save the updated organization entity. - $savedOrganisation = $organisationMapper->save($targetOrganisation); - - $this->_logger->info( - 'StackiqService: Successfully added admin users to organization entity', - [ - 'organizationUuid' => $organizationUuid, - 'addedUsers' => count($updatedUsers) - count($currentUsers), - 'totalUsers' => count($updatedUsers), - ] - ); - } else { - $this->_logger->info( - 'StackiqService: All admin users already in organization entity', - [ - 'organizationUuid' => $organizationUuid, - 'totalUsers' => count($updatedUsers), - ] - ); - }//end if - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Failed to add admin users to organization entity: ' . $e->getMessage(), - [ - 'organizationUuid' => $organizationUuid, - 'exception' => $e, - ] - ); - }//end try - }//end addAdminGroupUsersToOrganization() - - /** - * Checks if a contactpersoon username is in the organization's users list - * - * @param object $contactPersonObject The contactpersoon object - * - * @return bool True if the user should be added to the organization - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md - */ - public function shouldAddContactpersoonToOrganization(object $contactPersonObject): bool { + public function shouldAddContactpersoonToOrganization(object $contactPersonObject): bool { try { $objectData = $contactPersonObject->getObject(); $username = $objectData['username'] ?? ''; @@ -2530,559 +1077,148 @@ public function shouldAddContactpersoonToOrganization(object $contactPersonObjec // Check if the username is already in the organization's users. $organizationUsers = $organizationData['users'] ?? []; - if (is_array($organizationUsers) === true && in_array($username, $organizationUsers) === false) { - $this->_logger->info( - 'StackiqService: Contactpersoon should be added to organization', - [ - 'username' => $username, - 'organizationUuid' => $organizationUuid, - 'currentUsers' => $organizationUsers, - ] - ); - return true; - } - - return false; - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - // Organization doesn't exist, so we can't add the user. - $this->_logger->warning( - 'StackiqService: Organization not found for contactpersoon', - [ - 'username' => $username, - 'organizationUuid' => $organizationUuid, - ] - ); - return false; - }//end try - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Failed to check contactpersoon addition to org: ' . $e->getMessage(), - [ - 'objectId' => $contactPersonObject->getId(), - 'exception' => $e->getMessage(), - ] - ); - return false; - }//end try - }//end shouldAddContactpersoonToOrganization() - - /** - * Adds a contactpersoon username to the organization's users list - * - * @param object $contactPersonObject The contactpersoon object - * - * @return bool True if the user was successfully added - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md - */ - public function addContactpersoonToOrganization(object $contactPersonObject): bool { - try { - $objectData = $contactPersonObject->getObject(); - $username = $objectData['username'] ?? ''; - $organizationUuid = $objectData['organisation'] ?? ''; - - if (empty($username) === true || empty($organizationUuid) === true) { - $this->_logger->warning( - 'StackiqService: Cannot add contactpersoon to org - missing username or org', - [ - 'username' => $username, - 'organizationUuid' => $organizationUuid, - ] - ); - return false; - } - - $objectService = $this->getObjectService(); - if ($objectService === null) { - $this->_logger->error('StackiqService: OpenRegister ObjectService not available'); - return false; - } - - // Get the organization object. - $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'organization', logContext: 'organization'); - if ($ctx === null) { - return false; - } - - $registerId = $ctx['registerId']; - $organisationSchemaId = $ctx['schemaId']; - - try { - $organizationObject = $objectService->find($organizationUuid, [], false, $registerId, $organisationSchemaId); - $organizationData = $organizationObject->getObject(); - - // Add the username to the organization's users list. - $organizationUsers = $organizationData['users'] ?? []; - if (is_array($organizationUsers) === false) { - $organizationUsers = []; - } - - if (in_array($username, $organizationUsers) === false) { - $organizationUsers[] = $username; - $organizationData['users'] = $organizationUsers; - - // Update the organization object. - $updatedOrganization = $objectService->saveObject( - $organizationData, - [], - $registerId, - $organisationSchemaId, - $organizationUuid - ); - - $this->_logger->info( - 'StackiqService: Successfully added contactpersoon to organization', - [ - 'username' => $username, - 'organizationUuid' => $organizationUuid, - 'updatedUsers' => $organizationUsers, - ] - ); - }//end if - - $this->_logger->debug( - 'StackiqService: Contactpersoon already in organization', - [ - 'username' => $username, - 'organizationUuid' => $organizationUuid, - ] - ); - return true; - // Already there, consider it successful. - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - $this->_logger->error( - 'StackiqService: Organization not found for contactpersoon', - [ - 'username' => $username, - 'organizationUuid' => $organizationUuid, - ] - ); - return false; - }//end try - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Failed to add contact person to organization: ' . $e->getMessage(), - [ - 'objectId' => $contactPersonObject->getId(), - 'exception' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => $e->getTraceAsString(), - ] - ); - return false; - }//end try - }//end addContactpersoonToOrganization() - - /** - * Handles ownership assignment for anonymous user registrations - * - * @param object $organizationObject The organization object - * - * @return void - */ - private function handleOwnershipAssignment(object $organizationObject): void { - try { - $this->_logger->info( - 'StackiqService: Handling ownership assignment for organization', - [ - 'objectId' => $organizationObject->getId(), - ] - ); - - $objectData = $organizationObject->getObject(); - $organizationUuid = $objectData['id'] ?? $organizationObject->getId(); - $contactpersonen = $objectData['contactpersonen'] ?? []; - - if (empty($contactpersonen) === true) { - $this->_logger->info( - 'StackiqService: No contact persons found for ownership assignment', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - return; - } - - // Get the first contact person as the primary owner. - $primaryContactUuid = $contactpersonen[0]; - - $objectService = $this->getObjectService(); - if ($objectService === null) { - $this->_logger->error( - 'StackiqService: OpenRegister ObjectService not available for ownership assignment' - ); - return; - } - - // Get the primary contact person object. - $settingsService = $this->_container->get(SettingsService::class); - $registerId = $settingsService->getVoorzieningenRegisterId(); - $contactPersonSchemaId = $settingsService->getSchemaIdForObjectType('contactPerson'); - $organisationSchemaId = $settingsService->getSchemaIdForObjectType('organization'); - - if ($registerId === null || $contactPersonSchemaId === null || $organisationSchemaId === false) { - $this->_logger->error( - 'StackiqService: Register or schema not configured for contactpersoon/organisatie' - ); - return; - } - - // Retry mechanism for user creation timing. - $maxRetries = 3; - $retryDelay = 1; - // Seconds. - for ($retry = 0; $retry < $maxRetries; $retry++) { - try { - $primaryContactObject = $objectService->find( - $primaryContactUuid, - [], - false, - $registerId, - $contactPersonSchemaId - ); - $primaryContactData = $primaryContactObject->getObject(); - $primaryUsername = $primaryContactData['username'] ?? ''; - - if (empty($primaryUsername) === true) { - if ($retry < $maxRetries - 1) { - $this->_logger->info( - 'StackiqService: Primary contact no username, retry in ' . $retryDelay . 's', - [ - 'contactUuid' => $primaryContactUuid, - 'organizationUuid' => $organizationUuid, - 'retry' => $retry + 1, - 'maxRetries' => $maxRetries, - ] - ); - sleep($retryDelay); - } - - $this->_logger->warning( - 'StackiqService: Primary contact person still has no username after retries', - [ - 'contactUuid' => $primaryContactUuid, - 'organizationUuid' => $organizationUuid, - ] - ); - return; - }//end if - - // Get the organization entity UUID - use the same UUID as the organization object. - $organisationEntityUuid = $organizationUuid; - // Organization entity should have same UUID as object. - // Add users to the organization entity. - $organisationMapper = $this->_container->get('OCA\\OpenRegister\\Db\\OrganisationMapper'); - try { - $organisationEntity = $organisationMapper->findByUuid($organisationEntityUuid); - - // Add all contact person users to the organization entity. - foreach ($contactpersonen as $contactUuid) { - try { - $contactObject = $objectService->find( - $contactUuid, - [], - false, - $registerId, - $contactPersonSchemaId - ); - $contactData = $contactObject->getObject(); - $contactUsername = $contactData['username'] ?? ''; - - if (empty($contactUsername) === false) { - $organisationEntity->addUser($contactUsername); - } - } catch (\Exception $e) { - $this->_logger->warning( - 'StackiqService: Failed to add contact person to organization entity', - [ - 'contactUuid' => $contactUuid, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end foreach - - // Save the updated organization entity. - $organisationMapper->save($organisationEntity); - - $this->_logger->info( - 'StackiqService: Successfully added users to organization entity', - [ - 'organizationUuid' => $organisationEntityUuid, - 'userCount' => count($organisationEntity->getUserIds()), - ] - ); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - $this->_logger->error( - 'StackiqService: Organization entity not found for adding users', - [ - 'organizationUuid' => $organisationEntityUuid, - ] - ); - }//end try - - // Update organization object ownership and organization reference. - $organizationData['owner'] = $primaryUsername; - $organizationData['organisation'] = $organisationEntityUuid; - - $updatedOrganization = $objectService->saveObject( - $organizationData, - [], - $registerId, - $organisationSchemaId, - $organizationUuid - ); - - // Update primary contact person object ownership and organization reference. - $primaryContactData['owner'] = $primaryUsername; - $primaryContactData['organization'] = $organisationEntityUuid; - - $updatedPrimaryContact = $objectService->saveObject( - $primaryContactData, - [], - $registerId, - $contactPersonSchemaId, - $primaryContactUuid - ); - - // Update other contact persons with organization reference. - for ($i = 1; $i < count($contactpersonen); $i++) { - $contactUuid = $contactpersonen[$i]; - try { - $contactObject = $objectService->find( - $contactUuid, - [], - false, - $registerId, - $contactPersonSchemaId - ); - $contactData = $contactObject->getObject(); - $contactUsername = $contactData['username'] ?? ''; - - if (empty($contactUsername) === false) { - $contactData['owner'] = $contactUsername; - $contactData['organization'] = $organisationEntityUuid; - - $objectService->saveObject( - $contactData, - [], - $registerId, - $contactPersonSchemaId, - $contactUuid - ); - } - } catch (\Exception $e) { - $this->_logger->warning( - 'StackiqService: Failed to update contact person ownership', - [ - 'contactUuid' => $contactUuid, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end for - + if (is_array($organizationUsers) === true && in_array($username, $organizationUsers) === false) { $this->_logger->info( - 'StackiqService: Successfully assigned ownership for organization', + 'StackiqService: Contactpersoon should be added to organization', [ + 'username' => $username, 'organizationUuid' => $organizationUuid, - 'primaryOwner' => $primaryUsername, - 'organisationEntityUuid' => $organisationEntityUuid, - 'contactPersonCount' => count($contactpersonen), - 'retries' => $retry, + 'currentUsers' => $organizationUsers, ] ); + return true; + } - return; - // Success, exit retry loop. - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - if ($retry < $maxRetries - 1) { - $this->_logger->info( - 'StackiqService: Primary contact not found, retrying in ' . $retryDelay . ' seconds', - [ - 'contactUuid' => $primaryContactUuid, - 'organizationUuid' => $organizationUuid, - 'retry' => $retry + 1, - 'maxRetries' => $maxRetries, - ] - ); - sleep($retryDelay); - } - - $this->_logger->error( - 'StackiqService: Primary contact person not found after retries', - [ - 'contactUuid' => $primaryContactUuid, - 'organizationUuid' => $organizationUuid, - ] - ); - return; - }//end try - }//end for + return false; + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + // Organization doesn't exist, so we can't add the user. + $this->_logger->warning( + 'StackiqService: Organization not found for contactpersoon', + [ + 'username' => $username, + 'organizationUuid' => $organizationUuid, + ] + ); + return false; + }//end try } catch (\Exception $e) { $this->_logger->error( - 'StackiqService: Error handling ownership assignment', + 'StackiqService: Failed to check contactpersoon addition to org: ' . $e->getMessage(), [ - 'objectId' => $organizationObject->getId(), - 'error' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), + 'objectId' => $contactPersonObject->getId(), + 'exception' => $e->getMessage(), ] ); + return false; }//end try - }//end handleOwnershipAssignment() + }//end shouldAddContactpersoonToOrganization() /** - * Synchronizes contact person usernames with the organization entity's users array - * This method finds all contact persons associated with a given organization UUID - * and ensures their emails are present in the organization entity's users array + * Adds a contactpersoon username to the organization's users list * - * @param string $organizationUuid The UUID of the organization + * @param object $contactPersonObject The contactpersoon object * - * @return void + * @return bool True if the user was successfully added * @spec openspec/specs/softwarecatalogue-orchestration/spec.md */ - public function syncContactPersonUsernamesWithOrganization(string $organizationUuid): void { - $this->_logger->info( - 'StackiqService: Starting contact person username synchronization', - [ - 'organizationUuid' => $organizationUuid, - ] - ); + public function addContactpersoonToOrganization(object $contactPersonObject): bool { + try { + $objectData = $contactPersonObject->getObject(); + $username = $objectData['username'] ?? ''; + $organizationUuid = $objectData['organisation'] ?? ''; - // Get the ObjectService to find contact persons. - $objectService = $this->getObjectService(); - if ($objectService === null) { - $this->_logger->error('StackiqService: ObjectService not available for username synchronization'); - return; - } + if (empty($username) === true || empty($organizationUuid) === true) { + $this->_logger->warning( + 'StackiqService: Cannot add contactpersoon to org - missing username or org', + [ + 'username' => $username, + 'organizationUuid' => $organizationUuid, + ] + ); + return false; + } + + $objectService = $this->getObjectService(); + if ($objectService === null) { + $this->_logger->error('StackiqService: OpenRegister ObjectService not available'); + return false; + } - // Get the contact person schema ID from configuration. - $settingsService = $this->_container->get('OCA\Stackiq\Service\SettingsService'); - $voorzieningenConfig = $settingsService->getVoorzieningenConfig(); - $contactSchemaId = $voorzieningenConfig['contactpersoon_schema'] ?? null; - $registerId = $voorzieningenConfig['register'] ?? null; + // Get the organization object. + $ctx = $this->resolveVoorzieningenContext(schemaSlug: 'organization', logContext: 'organization'); + if ($ctx === null) { + return false; + } - if ($contactSchemaId === null || $registerId === false) { - $this->_logger->warning( - 'StackiqService: Missing Voorzieningen configuration for contact person sync', - [ - 'organizationUuid' => $organizationUuid, - 'contactSchemaId' => $contactSchemaId, - 'registerId' => $registerId, - ] - ); - return; - } + $registerId = $ctx['registerId']; + $organisationSchemaId = $ctx['schemaId']; - try { - // Find all contact persons that have this organization as their organisatie. - $contactPersons = $objectService->findAll( - [ - 'filters' => [ - 'register' => (string)$registerId, - 'schema' => $contactSchemaId, - 'organization' => $organizationUuid, - ], - ] - ); + try { + $organizationObject = $objectService->find($organizationUuid, [], false, $registerId, $organisationSchemaId); + $organizationData = $organizationObject->getObject(); - $this->_logger->info( - 'StackiqService: Found contact persons for synchronization', - [ - 'organizationUuid' => $organizationUuid, - 'contactPersonCount' => count($contactPersons), - ] - ); + // Add the username to the organization's users list. + $organizationUsers = $organizationData['users'] ?? []; + if (is_array($organizationUsers) === false) { + $organizationUsers = []; + } + + if (in_array($username, $organizationUsers) === false) { + $organizationUsers[] = $username; + $organizationData['users'] = $organizationUsers; + + // Update the organization object. + $updatedOrganization = $objectService->saveObject( + $organizationData, + [], + $registerId, + $organisationSchemaId, + $organizationUuid + ); - // Collect all usernames from contact persons. - $contactPersonUsernames = []; - foreach ($contactPersons as $contactPerson) { - $contactData = $contactPerson->getObject(); - $email = $contactData['email'] ?? null; - if (empty($email) === false) { - $contactPersonUsernames[] = $email; $this->_logger->info( - 'StackiqService: Found contact person username', + 'StackiqService: Successfully added contactpersoon to organization', [ - 'username' => $email, - 'contactPersonId' => $contactPerson->getId(), + 'username' => $username, + 'organizationUuid' => $organizationUuid, + 'updatedUsers' => $organizationUsers, ] ); - } - } + }//end if - // Get the organization entity. - $organisationMapper = $this->getOrganisationMapper(); - if ($organisationMapper === null) { - $this->_logger->error( - 'StackiqService: OpenRegister OrganisationMapper not available for synchronization', + $this->_logger->debug( + 'StackiqService: Contactpersoon already in organization', [ + 'username' => $username, 'organizationUuid' => $organizationUuid, ] ); - return; - } - - $organisation = $organisationMapper->findByUuid($organizationUuid); - - if ($organisation === null) { + return true; + // Already there, consider it successful. + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { $this->_logger->error( - 'StackiqService: Organization entity not found for synchronization', + 'StackiqService: Organization not found for contactpersoon', [ + 'username' => $username, 'organizationUuid' => $organizationUuid, ] ); - return; - } - - // Get current users and add contact person usernames. - $currentUsers = $organisation->getUsers() ?? []; - $allUsers = array_merge($currentUsers, $contactPersonUsernames); - $allUsers = array_unique($allUsers); - - $this->_logger->info( - 'StackiqService: Updating organization entity users', - [ - 'organizationUuid' => $organizationUuid, - 'currentUsers' => $currentUsers, - 'contactPersonUsernames' => $contactPersonUsernames, - 'finalUsers' => $allUsers, - ] - ); - - // Update the organization entity. - $organisation->setUsers($allUsers); - $organisationMapper->save($organisation); - - $this->_logger->info( - 'StackiqService: Successfully synchronized contact person usernames', - [ - 'organizationUuid' => $organizationUuid, - 'totalUsers' => count($allUsers), - ] - ); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - // Organization entity doesn't exist yet - this can happen due to race conditions. - // Log and return gracefully, the organization sync will handle this later. - $this->_logger->warning( - 'StackiqService: Organization entity not found during username sync (race condition)', - [ - 'organizationUuid' => $organizationUuid, - 'message' => 'Expected during anonymous registration - org entity created after contacts', - ] - ); + return false; + }//end try } catch (\Exception $e) { $this->_logger->error( - 'StackiqService: Error synchronizing contact person usernames', + 'StackiqService: Failed to add contact person to organization: ' . $e->getMessage(), [ - 'organizationUuid' => $organizationUuid, - 'error' => $e->getMessage(), + 'objectId' => $contactPersonObject->getId(), + 'exception' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), ] ); + return false; }//end try - }//end syncContactPersonUsernamesWithOrganization() + }//end addContactpersoonToOrganization() /** * Ensures a contact person's username is in their organization's users array. @@ -3205,161 +1341,6 @@ private function ensureContactPersonInOrganization(object $contactPersonObject): }//end try }//end ensureContactPersonInOrganization() - /** - * Updates organization references on objects to point to the newly created organization entity - * - * @param object $organizationObject The organization object - * - * @return void - */ - private function updateOrganizationReferences(object $organizationObject): void { - try { - $this->_logger->info( - 'StackiqService: Updating organization references', - [ - 'objectId' => $organizationObject->getId(), - ] - ); - - $objectData = $organizationObject->getObject(); - $organizationUuid = $objectData['id'] ?? $organizationObject->getId(); - - // Get the ObjectService to update objects. - $objectService = $this->getObjectService(); - if ($objectService === null) { - $this->_logger->error('StackiqService: ObjectService not available for updating references'); - return; - } - - // Get the organization entity UUID (should be the same as the organization object UUID). - $organisationMapper = $this->_container->get('OCA\\OpenRegister\\Db\\OrganisationMapper'); - try { - // Use the original UUID format for OpenRegister lookup. - $organisationEntity = $organisationMapper->findByUuid($organizationUuid); - $organisationEntityUuid = $organisationEntity->getUuid(); - - $this->_logger->info( - 'StackiqService: Found organization entity for reference update', - [ - 'organizationObjectUuid' => $organizationUuid, - 'organizationEntityUuid' => $organisationEntityUuid, - ] - ); - } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { - $this->_logger->error( - 'StackiqService: Organization entity not found for reference update', - [ - 'organizationUuid' => $organizationUuid, - ] - ); - return; - }//end try - - // Update the organization object's @self.organisation field. - $this->_logger->info( - 'StackiqService: Updating organization object reference', - [ - 'objectId' => $organizationObject->getId(), - 'newOrganisationUuid' => $organisationEntityUuid, - ] - ); - - // Get the current object data and update the organisation field. - $currentObjectData = $organizationObject->getObject(); - $currentObjectData['@self']['organisation'] = $organisationEntityUuid; - - // Update the organization object using the ObjectService. - // Don't update version, not a patch, no extend. - $objectService->saveObject( - object: $currentObjectData, - register: $organizationObject->getRegisterId(), - schema: $organizationObject->getSchemaId(), - uuid: $organizationObject->getUuid() - ); - - // Update contact person objects' @self.organization field. - $contactpersonen = $objectData['contactpersonen'] ?? []; - foreach ($contactpersonen as $contactUuid) { - $this->_logger->info( - 'StackiqService: Updating contact person object reference', - [ - 'contactUuid' => $contactUuid, - 'newOrganisationUuid' => $organisationEntityUuid, - ] - ); - - // Get the contact person schema ID from configuration. - $settingsService = $this->_container->get('OCA\Stackiq\Service\SettingsService'); - $voorzieningenConfig = $settingsService->getVoorzieningenConfig(); - $contactSchemaId = $voorzieningenConfig['contactpersoon_schema'] ?? null; - - if ($contactSchemaId === null) { - $this->_logger->warning( - 'StackiqService: Missing contactpersoon schema configuration for object update', - [ - 'contactUuid' => $contactUuid, - ] - ); - continue; - } - - // Find the contact person object. - try { - $regId = $organizationObject->getRegisterId(); - $contactObject = $objectService->find( - $contactUuid, - [], - false, - $regId, - $contactSchemaId - ); - if (empty($contactObject) === false) { - // Get the current object data and update the organisatie field. - $contactObjectData = $contactObject->getObject(); - $contactObjectData['@self']['organization'] = $organisationEntityUuid; - - // Update the contact person object using the ObjectService. - // Don't update version, not a patch, no extend. - $objectService->saveObject( - object: $contactObjectData, - register: $organizationObject->getRegisterId(), - schema: $contactSchemaId, - uuid: $contactObject->getUuid() - ); - } - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Failed to update contact person object', - [ - 'contactUuid' => $contactUuid, - 'error' => $e->getMessage(), - ] - ); - }//end try - }//end foreach - - $this->_logger->info( - 'StackiqService: Successfully updated organization references', - [ - 'organizationUuid' => $organizationUuid, - 'organizationEntityUuid' => $organisationEntityUuid, - 'contactPersonCount' => count($contactpersonen), - ] - ); - } catch (\Exception $e) { - $this->_logger->error( - 'StackiqService: Failed to update organization references: ' . $e->getMessage(), - [ - 'objectId' => $organizationObject->getId(), - 'exception' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - 'trace' => $e->getTraceAsString(), - ] - ); - }//end try - }//end updateOrganizationReferences() - /** * Resolves the voorzieningen register id + a schema id for a given * object-type slug. diff --git a/tests/OrganizationSyncTest.php b/tests/OrganizationSyncTest.php deleted file mode 100644 index 904a01da..00000000 --- a/tests/OrganizationSyncTest.php +++ /dev/null @@ -1,272 +0,0 @@ - - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @version 1.0.0 - * @link https://github.com/ConductionNL/stackiq - */ - -declare(strict_types=1); - -namespace OCA\Stackiq\Tests; - -use OCA\OpenRegister\Contract\ObjectServiceInterface; -use OCA\Stackiq\Service\SettingsService; -use OCA\Stackiq\Service\StackiqService; -use OCP\AppFramework\Db\Entity; -use OCP\IGroupManager; -use OCP\IUserManager; -use PHPUnit\Framework\TestCase; - -/** - * Test class for organization synchronization functionality - * - * @category Test - * @package OCA\Stackiq\Tests - * @author Conduction b.v. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @version 1.0.0 - * @link https://github.com/ConductionNL/stackiq - */ -class OrganizationSyncTest extends TestCase { - /** - * Test organization creation and OpenRegister synchronization - * - * @return void - */ - public function testOrganizationCreationSync(): void { - // Mock dependencies - $objectService = $this->createMock(ObjectServiceInterface::class); - $settingsService = $this->createMock(SettingsService::class); - $userManager = $this->createMock(IUserManager::class); - $groupManager = $this->createMock(IGroupManager::class); - - // Configure mocks - $settingsService->method('getRegisterIdForObjectType') - ->with('organization') - ->willReturn(1); - - $settingsService->method('getSchemaIdForObjectType') - ->with('organization') - ->willReturn(37); - - // Test organization data - $organizationData = [ - 'name' => 'Test Organization', - 'type' => 'Municipality', - 'website' => 'https://test.org', - 'beoordeling' => 'actief', - 'id' => 'test-org-uuid-123' - ]; - - // Mock organization object - $organizationObject = $this->createMock(Entity::class); - $organizationObject->method('getId') - ->willReturn('test-org-uuid-123'); - $organizationObject->method('getObject') - ->willReturn($organizationData); - - // Mock OpenRegister response - $openRegisterObject = $this->createMock(Entity::class); - $openRegisterObject->method('getId') - ->willReturn('test-org-uuid-123'); - - $objectService->method('find') - ->willThrowException(new \OCP\AppFramework\Db\DoesNotExistException('Not found')); - - $objectService->method('saveObject') - ->willReturn($openRegisterObject); - - // Create service instance - $service = new StackiqService( - $this->createMock(\OCA\Stackiq\Service\Stackiq\OrganizationHandler::class), - $this->createMock(\OCA\Stackiq\Service\Stackiq\ContactPersonHandler::class), - $this->createMock(\OCA\Stackiq\Service\Stackiq\GroupHandler::class), - $this->createMock(\OCA\Stackiq\Service\Stackiq\HierarchyHandler::class), - $this->createMock(\OCA\Stackiq\Service\SymfonyEmailService::class), - $this->createMock(\Psr\Log\LoggerInterface::class), - $this->createMock(\Psr\Container\ContainerInterface::class), - $this->createMock(\OCP\App\IAppManager::class), - _userSession: $this->createMock(IUserSession::class), - _userManager: $this->createMock(IUserManager::class), - _groupManager: $this->createMock(IGroupManager::class), - ); - - // Test synchronization - $result = $service->syncOrganizationWithOpenRegister($organizationObject); - - // Assertions - $this->assertTrue($result, 'Organization sync should succeed'); - } - - /** - * Test organization status mapping - * - * @return void - */ - public function testOrganizationStatusMapping(): void { - // Test data mapping - $testCases = [ - [ - 'input' => ['beoordeling' => 'actief'], - 'expected' => 'active' - ], - [ - 'input' => ['beoordeling' => 'inactief'], - 'expected' => 'inactive' - ], - [ - 'input' => ['beoordeling' => 'deactief'], - 'expected' => 'inactive' - ], - [ - 'input' => ['beoordeling' => 'unknown'], - 'expected' => 'Draft' - ] - ]; - - foreach ($testCases as $testCase) { - $mappedData = $this->mapOrganizationDataForOpenRegister($testCase['input']); - $this->assertEquals($testCase['expected'], $mappedData['status'], - 'Status mapping should work correctly for: ' . $testCase['input']['beoordeling']); - } - } - - /** - * Test contactpersoon organization membership - * - * @return void - */ - public function testContactpersoonOrganizationMembership(): void { - // Mock dependencies - $objectService = $this->createMock(ObjectServiceInterface::class); - $settingsService = $this->createMock(SettingsService::class); - - // Configure mocks - $settingsService->method('getVoorzieningenRegisterId') - ->willReturn(1); - - $settingsService->method('getSchemaIdForObjectType') - ->with('organization') - ->willReturn(37); - - // Test contactpersoon data - $contactPersonData = [ - 'username' => 'test.user@example.com', - 'organisation' => 'test-org-uuid-123' - ]; - - // Mock contactpersoon object - $contactPersonObject = $this->createMock(Entity::class); - $contactPersonObject->method('getId') - ->willReturn('test-contact-uuid-456'); - $contactPersonObject->method('getObject') - ->willReturn($contactPersonData); - - // Mock organization data - $organizationData = [ - 'users' => ['existing.user@example.com'] - ]; - - // Mock organization object - $organizationObject = $this->createMock(Entity::class); - $organizationObject->method('getObject') - ->willReturn($organizationData); - - $objectService->method('find') - ->willReturn($organizationObject); - - // Test should add to organization - $result = $this->shouldAddContactpersoonToOrganization($contactPersonObject); - $this->assertTrue($result, 'Contactpersoon should be added to organization'); - } - - /** - * Test user status management based on organization status - * - * @return void - */ - public function testUserStatusManagement(): void { - // Mock user manager - $userManager = $this->createMock(IUserManager::class); - - // Mock user - $user = $this->createMock(\OCP\IUser::class); - $user->method('isEnabled') - ->willReturn(false); - $user->method('setEnabled') - ->willReturnSelf(); - - $userManager->method('get') - ->willReturn($user); - - // Test user activation - $this->assertFalse($user->isEnabled(), 'User should be inactive initially'); - - // Simulate activation - $user->setEnabled(true); - $this->assertTrue($user->isEnabled(), 'User should be active after activation'); - } - - /** - * Helper method to map organization data for OpenRegister - * - * @param array $objectData The organization data from Stackiq - * - * @return array The mapped data for OpenRegister - */ - private function mapOrganizationDataForOpenRegister(array $objectData): array { - $mappedData = [ - 'name' => $objectData['name'] ?? '', - 'type' => $objectData['type'] ?? '', - 'website' => $objectData['website'] ?? '', - 'active' => false, // Default to inactive for new organizations - 'contactpersonen' => [], - 'participants' => [] - ]; - - // Map status from Stackiq to OpenRegister - $assessment = strtolower($objectData['beoordeling'] ?? ''); - if ($assessment === 'actief') { - $mappedData['active'] = true; - } elseif ($assessment === 'inactief' || $assessment === 'deactief') { - $mappedData['active'] = false; - } - - return $mappedData; - } - - /** - * Helper method to check if contactpersoon should be added to organization - * - * @param object $contactPersonObject The contactpersoon object - * - * @return bool True if the user should be added to the organization - */ - private function shouldAddContactpersoonToOrganization(object $contactPersonObject): bool { - $objectData = $contactPersonObject->getObject(); - $username = $objectData['username'] ?? ''; - $organizationUuid = $objectData['organisation'] ?? ''; - - if (empty($username) || empty($organizationUuid)) { - return false; - } - - // Mock organization data - $organizationData = [ - 'users' => ['existing.user@example.com'] - ]; - - // Check if the username is already in the organization's users - $organizationUsers = $organizationData['users'] ?? []; - - return is_array($organizationUsers) && !in_array($username, $organizationUsers); - } -} diff --git a/tests/Stubs/Db/Organisation.php b/tests/Stubs/Db/Organisation.php index 99de7d97..6a79b148 100644 --- a/tests/Stubs/Db/Organisation.php +++ b/tests/Stubs/Db/Organisation.php @@ -5,10 +5,7 @@ * * The real Organisation is an NC AppFramework Entity whose accessors are * partly magic. This stub declares the concrete surface the Stackiq - * unit tests exercise (uuid + parent + a couple of read accessors), and - * answers the other accessors and the membership helpers (getUserIds, - * addUser) through __call, as the Entity does. Test doubles that extend it - * and override __call keep recording those calls. + * unit tests exercise (uuid + parent + a couple of read accessors). * * SPDX-License-Identifier: EUPL-1.2 * @@ -45,13 +42,6 @@ class Organisation { */ private bool $active = true; - /** - * The magic properties, by name. - * - * @var array - */ - private array $properties = []; - /** * @param int|null $id The id. * @@ -117,40 +107,4 @@ public function isActive(): bool { return $this->active; }//end isActive() - /** - * Answer the magic accessors and the membership helpers. - * - * @param string $name The method. - * @param array $args The arguments. - * - * @return mixed - */ - public function __call(string $name, array $args) { - if ($name === 'getUserIds') { - return $this->properties['users'] ?? []; - } - - if ($name === 'addUser') { - $users = $this->properties['users'] ?? []; - if (in_array($args[0], $users, true) === false) { - $users[] = $args[0]; - } - - $this->properties['users'] = $users; - return $this; - } - - $property = lcfirst(substr($name, 3)); - if (str_starts_with($name, 'set') === true) { - $this->properties[$property] = $args[0] ?? null; - return $this; - } - - if (str_starts_with($name, 'get') === true) { - return $this->properties[$property] ?? null; - } - - throw new \BadFunctionCallException($name . ' is not a valid method'); - }//end __call() - }//end class diff --git a/tests/Stubs/Db/OrganisationMapper.php b/tests/Stubs/Db/OrganisationMapper.php index b4eb0f1a..e7618229 100644 --- a/tests/Stubs/Db/OrganisationMapper.php +++ b/tests/Stubs/Db/OrganisationMapper.php @@ -49,14 +49,4 @@ abstract public function findBySlug(string $slug): Organisation; */ abstract public function save(Organisation $entity): Organisation; - /** - * Add a user to an organisation. - * - * @param string $organisationUuid The organisation uuid. - * @param string $userId The user id. - * - * @return Organisation - */ - abstract public function addUserToOrganisation(string $organisationUuid, string $userId): Organisation; - }//end class diff --git a/tests/Stubs/Service/OrganisationService.php b/tests/Stubs/Service/OrganisationService.php index bb1695b5..d535023c 100644 --- a/tests/Stubs/Service/OrganisationService.php +++ b/tests/Stubs/Service/OrganisationService.php @@ -79,21 +79,4 @@ abstract public function joinOrganisation(string $organisationUuid, ?string $tar */ abstract public function leaveOrganisation(string $organisationUuid, ?string $targetUserId = null): bool; - /** - * Create an organisation; with addCurrentUser the signed-in user becomes its owner and a member. - * - * @param string $name The name. - * @param string $description The description. - * @param bool $addCurrentUser Whether the signed-in user owns and joins it. - * @param string $uuid The uuid to use. - * - * @return Organisation - */ - abstract public function createOrganisation( - string $name, - string $description = '', - bool $addCurrentUser = true, - string $uuid = '', - ): Organisation; - }//end class diff --git a/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php b/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php deleted file mode 100644 index 887003e4..00000000 --- a/tests/Unit/Service/StackiqServiceOrganisationSyncGoldenTest.php +++ /dev/null @@ -1,417 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @link https://github.com/ConductionNL/stackiq - * - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md - */ - -declare(strict_types=1); - -namespace OCA\Stackiq\Tests\Unit\Service; - -use OCA\OpenRegister\Contract\ObjectServiceInterface; -use OCA\OpenRegister\Db\Organisation; -use OCA\OpenRegister\Db\OrganisationMapper; -use OCA\OpenRegister\Service\OrganisationService; -use OCA\Stackiq\Service\SettingsService; -use OCA\Stackiq\Service\Stackiq\ContactPersonHandler; -use OCA\Stackiq\Service\Stackiq\GroupHandler; -use OCA\Stackiq\Service\Stackiq\HierarchyHandler; -use OCA\Stackiq\Service\Stackiq\OrganizationHandler; -use OCA\Stackiq\Service\StackiqService; -use OCA\Stackiq\Service\SymfonyEmailService; -use OCP\App\IAppManager; -use OCP\AppFramework\Db\DoesNotExistException; -use OCP\IGroupManager; -use OCP\IUser; -use OCP\IUserManager; -use OCP\IUserSession; -use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; -use Psr\Log\AbstractLogger; - -/** - * Golden cases for the organisation sync with OpenRegister. - * - * @covers \OCA\Stackiq\Service\StackiqService - * - * @spec openspec/specs/softwarecatalogue-orchestration/spec.md - */ -class StackiqServiceOrganisationSyncGoldenTest extends TestCase { - - /** - * Everything the doubles saw, in order. - * - * @var array - */ - private array $trace = []; - - /** - * Per-case behaviour of the doubles. - * - * @var array - */ - private array $plan = []; - - /** - * The golden file. - * - * @return string - */ - private static function golden(): string { - return __DIR__ . '/golden/stackiqServiceOrganisationSync.json'; - - }//end golden() - - /** - * The cases: entry point, organisation data and the doubles' behaviour. - * - * @return array}> - */ - public static function cases(): array { - $org = [ - 'id' => 'org-1', - 'name' => 'Gemeente A', - 'website' => 'https://a.nl', - 'beoordeling' => 'actief', - 'contactpersonen' => [['email' => 'a@a.nl'], ['naam' => 'zonder mail'], 'c-2'], - ]; - return [ - 'sync, OpenRegister disabled' => ['sync', $org, ['disabled' => true]], - 'sync, existing organisation' => ['sync', $org, ['exists' => true]], - 'sync, existing, save fails' => ['sync', $org, ['exists' => true, 'saveThrows' => true]], - 'sync, new, signed in' => ['sync', $org, []], - 'sync, new, signed in with schema' => ['sync', $org, ['schema' => true, 'contacts' => [['email' => 'b@a.nl', 'organization' => 'org-1'], ['email' => 'c@a.nl', 'organization' => 'org-9']]]], - 'sync, new, anonymous' => ['sync', $org, ['anonymous' => true]], - 'sync, new, organisation service down' => ['sync', $org, ['serviceThrows' => true]], - 'create, no id' => ['create', ['name' => 'X'], []], - 'create, signed in' => ['create', $org, []], - 'create, organisation service down' => ['create', $org, ['serviceThrows' => true]], - 'create, anonymous, no mapper' => ['create', $org, ['anonymous' => true, 'noMapper' => true]], - 'create, anonymous' => ['create', $org, ['anonymous' => true]], - 'create, forged owner and members' => ['create', $org + ['owner' => 'mallory', 'users' => ['mallory'], 'participants' => ['mallory']], []], - 'create, creator is a contact' => ['create', ['contactpersonen' => [['email' => 'signed-in'], ['email' => 'a@a.nl']]] + $org, []], - 'create, no mapper' => ['create', $org, ['noMapper' => true]], - ]; - - }//end cases() - - /** - * Each case matches its recording. - * - * @param string $entry Which entry point runs. - * @param array $data The organisation object data. - * @param array $plan The behaviour of the doubles. - * - * @return void - * - * @dataProvider cases - */ - public function testSyncMatchesTheRecording(string $entry, array $data, array $plan): void { - $this->plan = $plan; - $this->trace = []; - $service = $this->service(); - try { - if ($entry === 'sync') { - $result = $service->syncOrganizationWithOpenRegister($this->stored(data: $data, id: 17)); - } else { - $result = $service->createOrganisationInOpenRegister($data); - if (is_object($result) === true) { - $result = 'entity:' . $result->getUuid(); - } - } - } catch (\Throwable $e) { - $result = ['escaped' => get_class($e), 'message' => preg_replace('/Mock\w+/', 'Mock', $e->getMessage())]; - } - - $this->check(key: $entry . ': ' . $this->dataName(), value: ['result' => $result, 'calls' => $this->trace]); - - }//end testSyncMatchesTheRecording() - - /** - * The service with recording doubles. - * - * @return StackiqService - */ - private function service(): StackiqService { - $appManager = $this->createMock(IAppManager::class); - $appManager->method('isEnabledForUser')->willReturn(isset($this->plan['disabled']) === false); - - $mapper = $this->mapper(); - $objects = $this->objectService(); - $settings = $this->createMock(SettingsService::class); - $settings->method('getVoorzieningenConfig')->willReturnCallback( - function (): array { - $this->trace[] = ['settings.getVoorzieningenConfig']; - return isset($this->plan['schema']) === true ? ['contactpersoon_schema' => '9'] : []; - } - ); - $organisations = $this->createMock(OrganisationService::class); - $organisations->method('createOrganisation')->willReturnCallback( - function (string $name, string $description = '', bool $addCurrentUser = true, string $uuid = '') { - $this->trace[] = ['organisations.createOrganisation', $name, $description, $addCurrentUser, $uuid]; - return $this->organisation(uuid: $uuid, users: ['signed-in']); - } - ); - - $container = $this->createMock(ContainerInterface::class); - $container->method('get')->willReturnCallback( - function (string $id) use ($mapper, $objects, $settings, $organisations) { - $this->trace[] = ['container.get', $id]; - if ($id === 'OCA\OpenRegister\Service\OrganisationService' && isset($this->plan['serviceThrows']) === true) { - throw new \RuntimeException('organisation service down'); - } - - if ($id === 'OCA\OpenRegister\Db\OrganisationMapper' && isset($this->plan['noMapper']) === true) { - throw new \RuntimeException('no mapper'); - } - - $answers = [ - 'OCA\OpenRegister\Db\OrganisationMapper' => $mapper, - 'OCA\OpenRegister\Service\ObjectService' => $objects, - 'OCA\Stackiq\Service\SettingsService' => $settings, - 'OCA\OpenRegister\Service\OrganisationService' => $organisations, - ]; - return $answers[$id] ?? null; - } - ); - - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('signed-in'); - $userSession = $this->createMock(IUserSession::class); - $userSession->method('getUser')->willReturn(isset($this->plan['anonymous']) === true ? null : $user); - - return new StackiqService( - _organizationHandler: $this->createMock(OrganizationHandler::class), - _contactPersonHandler: $this->createMock(ContactPersonHandler::class), - _groupHandler: $this->createMock(GroupHandler::class), - _hierarchyHandler: $this->createMock(HierarchyHandler::class), - _emailService: $this->createMock(SymfonyEmailService::class), - _logger: $this->logger(), - _container: $container, - _appManager: $appManager, - _userSession: $userSession, - _userManager: $this->createMock(IUserManager::class), - _groupManager: $this->createMock(IGroupManager::class) - ); - - }//end service() - - /** - * An organisation mapper that records lookups and saves. - * - * @return OrganisationMapper - */ - private function mapper(): OrganisationMapper { - $mapper = $this->createMock(OrganisationMapper::class); - $mapper->method('findByUuid')->willReturnCallback( - function (string $uuid) { - $this->trace[] = ['mapper.findByUuid', $uuid]; - if (isset($this->plan['exists']) === false) { - throw new DoesNotExistException('missing'); - } - - return $this->organisation(uuid: $uuid); - } - ); - $mapper->method('addUserToOrganisation')->willReturnCallback( - function (string $uuid, string $userId) { - $this->trace[] = ['mapper.addUserToOrganisation', $uuid, $userId]; - return $this->organisation(uuid: $uuid, users: ['signed-in', $userId]); - } - ); - $mapper->method('save')->willReturnCallback( - function ($organisation) { - $this->trace[] = ['mapper.save', $organisation->getUuid()]; - if (isset($this->plan['saveThrows']) === true) { - throw new \RuntimeException('save failed'); - } - - return $organisation; - } - ); - - return $mapper; - - }//end mapper() - - /** - * An organisation entity double that records its writes. - * - * @param string $uuid The uuid. - * @param array $users The member user ids getUserIds() answers. - * - * @return Organisation - */ - private function organisation(string $uuid, array $users = []): Organisation { - $trace = &$this->trace; - $organisation = new class($trace, $users) extends Organisation { - /** - * @param array $trace The shared trace. - * @param array $users The member user ids. - */ - public function __construct(private array &$trace, private array $users) { - } - - /** - * @param string $name The method. - * @param array $args The arguments. - * - * @return mixed - */ - public function __call(string $name, array $args) { - $this->trace[] = ['organisation.' . $name, $args]; - if ($name === 'getUserIds') { - return $this->users; - } - - return null; - } - }; - $organisation->setUuid($uuid); - return $organisation; - - }//end organisation() - - /** - * An object service that answers the contact person lookups. - * - * @return ObjectServiceInterface - */ - private function objectService(): ObjectServiceInterface { - $objects = $this->createMock(ObjectServiceInterface::class); - $objects->method('findAll')->willReturnCallback( - function (array $config) { - $this->trace[] = ['objects.findAll', $config]; - $found = []; - foreach ($this->plan['contacts'] ?? [] as $i => $data) { - $found[] = $this->stored(data: $data, id: 100 + $i); - } - - return $found; - } - ); - - return $objects; - - }//end objectService() - - /** - * A stored object double with an id and its data. - * - * @param array $data The object data. - * @param int $id The object id. - * - * @return object - */ - private function stored(array $data, int $id): object { - return new class($data, $id) { - /** - * @param array $data The object data. - * @param int $id The object id. - */ - public function __construct(private array $data, private int $id) { - } - - /** - * @return int - */ - public function getId(): int { - return $this->id; - } - - /** - * @return array - */ - public function getObject(): array { - return $this->data; - } - }; - - }//end stored() - - /** - * A logger that records every line, without stack traces. - * - * @return AbstractLogger - */ - private function logger(): AbstractLogger { - $trace = &$this->trace; - return new class($trace) extends AbstractLogger { - /** - * @param array $trace The shared trace. - */ - public function __construct(private array &$trace) { - } - - /** - * @param mixed $level The level. - * @param string|\Stringable $message The message. - * @param array $context The context. - * - * @return void - */ - public function log($level, string|\Stringable $message, array $context = []): void { - foreach (['trace', 'file', 'line'] as $volatile) { - if (array_key_exists($volatile, $context) === true) { - $context[$volatile] = '(' . $volatile . ')'; - } - } - - array_walk_recursive( - $context, - function (&$value) { - if (is_string($value) === true) { - $value = preg_replace(['/Mock\w+/', '/@anonymous.*/s'], ['Mock', '@anonymous'], $value); - } - } - ); - $this->trace[] = ['log.' . $level, preg_replace('/Mock\w+/', 'Mock', (string) $message), $context]; - } - }; - - }//end logger() - - /** - * Compare a case with the golden file, or record it. - * - * @param string $key The case. - * @param array $value What the case observed. - * - * @return void - */ - private function check(string $key, array $value): void { - $json = json_decode(json_encode($value, JSON_PARTIAL_OUTPUT_ON_ERROR), true); - $file = self::golden(); - $saved = []; - if (file_exists($file) === true) { - $saved = json_decode((string) file_get_contents($file), true); - } - - if (getenv('RECORD_GOLDEN') !== false) { - $saved[$key] = $json; - file_put_contents($file, json_encode($saved, (JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) . "\n"); - $this->addToAssertionCount(count: 1); - return; - } - - $this->assertArrayHasKey(key: $key, array: $saved, message: 'No golden for ' . $key); - $this->assertSame(expected: $saved[$key], actual: $json); - - }//end check() - -}//end class diff --git a/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json b/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json deleted file mode 100644 index 09a0cc8a..00000000 --- a/tests/Unit/Service/golden/stackiqServiceOrganisationSync.json +++ /dev/null @@ -1,1196 +0,0 @@ -{ - "sync: sync, OpenRegister disabled": { - "result": false, - "calls": [ - [ - "log.info", - "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", - { - "objectId": 17, - "objectClass": "class@anonymous" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_2 - Extracted organization data", - { - "organizationUuid": "org-1", - "objectDataKeys": [ - "id", - "name", - "website", - "beoordeling", - "contactpersonen" - ], - "hasId": true - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - Getting OrganisationService", - [] - ], - [ - "log.error", - "StackiqService: SYNC_STEP_3 - OpenRegister OrganisationService not available", - [] - ] - ] - }, - "sync: sync, existing organisation": { - "result": true, - "calls": [ - [ - "log.info", - "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", - { - "objectId": 17, - "objectClass": "class@anonymous" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_2 - Extracted organization data", - { - "organizationUuid": "org-1", - "objectDataKeys": [ - "id", - "name", - "website", - "beoordeling", - "contactpersonen" - ], - "hasId": true - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - Getting OrganisationService", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", - { - "serviceClass": "Mock" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_4 - OpenRegister configuration", - { - "organizationUuid": "org-1", - "organizationName": "Gemeente A" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", - [] - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5B - Calling findByUuid", - { - "uuid": "org-1" - } - ], - [ - "mapper.findByUuid", - "org-1" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_6 - Organization exists in OpenRegister, updating", - { - "organizationId": "org-1", - "existingOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_7 - Mapping organization data", - [] - ], - [ - "log.info", - "StackiqService: Updating organization in OpenRegister", - { - "organizationUuid": "org-1", - "name": "Gemeente A" - } - ], - [ - "organisation.setName", - [ - "Gemeente A" - ] - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "mapper.save", - "org-1" - ], - [ - "log.info", - "StackiqService: Successfully updated organization in OpenRegister", - { - "organizationUuid": "org-1", - "openRegisterId": "org-1" - } - ], - [ - "log.info", - "StackiqService: Successfully updated organization in OpenRegister", - { - "organizationId": "org-1", - "openRegisterId": "org-1" - } - ] - ] - }, - "sync: sync, existing, save fails": { - "result": false, - "calls": [ - [ - "log.info", - "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", - { - "objectId": 17, - "objectClass": "class@anonymous" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_2 - Extracted organization data", - { - "organizationUuid": "org-1", - "objectDataKeys": [ - "id", - "name", - "website", - "beoordeling", - "contactpersonen" - ], - "hasId": true - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - Getting OrganisationService", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", - { - "serviceClass": "Mock" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_4 - OpenRegister configuration", - { - "organizationUuid": "org-1", - "organizationName": "Gemeente A" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", - [] - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5B - Calling findByUuid", - { - "uuid": "org-1" - } - ], - [ - "mapper.findByUuid", - "org-1" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_6 - Organization exists in OpenRegister, updating", - { - "organizationId": "org-1", - "existingOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_7 - Mapping organization data", - [] - ], - [ - "log.info", - "StackiqService: Updating organization in OpenRegister", - { - "organizationUuid": "org-1", - "name": "Gemeente A" - } - ], - [ - "organisation.setName", - [ - "Gemeente A" - ] - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "mapper.save", - "org-1" - ], - [ - "log.error", - "StackiqService: Failed to sync organization with OpenRegister: save failed", - { - "objectId": 17, - "exception": "save failed", - "file": "(file)", - "line": "(line)", - "trace": "(trace)" - } - ] - ] - }, - "sync: sync, new, signed in": { - "result": true, - "calls": [ - [ - "log.info", - "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", - { - "objectId": 17, - "objectClass": "class@anonymous" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_2 - Extracted organization data", - { - "organizationUuid": "org-1", - "objectDataKeys": [ - "id", - "name", - "website", - "beoordeling", - "contactpersonen" - ], - "hasId": true - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - Getting OrganisationService", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", - { - "serviceClass": "Mock" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_4 - OpenRegister configuration", - { - "organizationUuid": "org-1", - "organizationName": "Gemeente A" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", - [] - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5B - Calling findByUuid", - { - "uuid": "org-1" - } - ], - [ - "mapper.findByUuid", - "org-1" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_8 - Organization not found in OpenRegister, creating", - { - "organizationId": "org-1", - "exception": "missing" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_9 - Mapping organization data for creation", - [] - ], - [ - "log.info", - "StackiqService: SYNC_STEP_10 - Calling createOrganisationInOpenRegister", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "organisations.createOrganisation", - "Gemeente A", - "https://a.nl", - true, - "org-1" - ], - [ - "log.info", - "StackiqService: Processing nested contact persons", - { - "organizationUuid": "org-1", - "nestedContactPersonCount": 0 - } - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\ObjectService" - ], - [ - "container.get", - "OCA\\Stackiq\\Service\\SettingsService" - ], - [ - "settings.getVoorzieningenConfig" - ], - [ - "log.warning", - "StackiqService: Missing contactpersoon schema config for username extraction", - [] - ], - [ - "organisation.getUserIds", - [] - ], - [ - "log.info", - "StackiqService: Created organization in OpenRegister", - { - "organizationUuid": "org-1", - "openRegisterId": "org-1", - "contactPersons": 0 - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_11 - Successfully created organization in OpenRegister", - { - "organizationId": "org-1", - "openRegisterId": "org-1", - "createdOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous" - } - ] - ] - }, - "sync: sync, new, signed in with schema": { - "result": true, - "calls": [ - [ - "log.info", - "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", - { - "objectId": 17, - "objectClass": "class@anonymous" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_2 - Extracted organization data", - { - "organizationUuid": "org-1", - "objectDataKeys": [ - "id", - "name", - "website", - "beoordeling", - "contactpersonen" - ], - "hasId": true - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - Getting OrganisationService", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", - { - "serviceClass": "Mock" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_4 - OpenRegister configuration", - { - "organizationUuid": "org-1", - "organizationName": "Gemeente A" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", - [] - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5B - Calling findByUuid", - { - "uuid": "org-1" - } - ], - [ - "mapper.findByUuid", - "org-1" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_8 - Organization not found in OpenRegister, creating", - { - "organizationId": "org-1", - "exception": "missing" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_9 - Mapping organization data for creation", - [] - ], - [ - "log.info", - "StackiqService: SYNC_STEP_10 - Calling createOrganisationInOpenRegister", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "organisations.createOrganisation", - "Gemeente A", - "https://a.nl", - true, - "org-1" - ], - [ - "log.info", - "StackiqService: Processing nested contact persons", - { - "organizationUuid": "org-1", - "nestedContactPersonCount": 0 - } - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\ObjectService" - ], - [ - "container.get", - "OCA\\Stackiq\\Service\\SettingsService" - ], - [ - "settings.getVoorzieningenConfig" - ], - [ - "objects.findAll", - { - "filters": { - "register": "6", - "schema": "9", - "organization": "org-1" - } - } - ], - [ - "log.info", - "StackiqService: Found existing contact persons for organization", - { - "organizationUuid": "org-1", - "contactPersonCount": 2, - "contactPersonIds": [ - 100, - 101 - ] - } - ], - [ - "log.info", - "StackiqService: Added existing contact person username", - { - "username": "b@a.nl", - "contactPersonId": 100 - } - ], - [ - "log.info", - "StackiqService: Added existing contact person username", - { - "username": "c@a.nl", - "contactPersonId": 101 - } - ], - [ - "log.info", - "StackiqService: Collected contact person usernames", - { - "organizationUuid": "org-1", - "totalUsernames": 2, - "usernames": [ - "b@a.nl", - "c@a.nl" - ] - } - ], - [ - "organisation.getUserIds", - [] - ], - [ - "mapper.addUserToOrganisation", - "org-1", - "b@a.nl" - ], - [ - "mapper.addUserToOrganisation", - "org-1", - "c@a.nl" - ], - [ - "log.info", - "StackiqService: Created organization in OpenRegister", - { - "organizationUuid": "org-1", - "openRegisterId": "org-1", - "contactPersons": 2 - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_11 - Successfully created organization in OpenRegister", - { - "organizationId": "org-1", - "openRegisterId": "org-1", - "createdOrganisationClass": "OCA\\OpenRegister\\Db\\Organisation@anonymous" - } - ] - ] - }, - "sync: sync, new, anonymous": { - "result": false, - "calls": [ - [ - "log.info", - "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", - { - "objectId": 17, - "objectClass": "class@anonymous" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_2 - Extracted organization data", - { - "organizationUuid": "org-1", - "objectDataKeys": [ - "id", - "name", - "website", - "beoordeling", - "contactpersonen" - ], - "hasId": true - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - Getting OrganisationService", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - OrganisationService retrieved", - { - "serviceClass": "Mock" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_4 - OpenRegister configuration", - { - "organizationUuid": "org-1", - "organizationName": "Gemeente A" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5 - Checking if organization exists in OpenRegister", - [] - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5A - Getting OrganisationMapper for lookup", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_5B - Calling findByUuid", - { - "uuid": "org-1" - } - ], - [ - "mapper.findByUuid", - "org-1" - ], - [ - "log.info", - "StackiqService: SYNC_STEP_8 - Organization not found in OpenRegister, creating", - { - "organizationId": "org-1", - "exception": "missing" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_9 - Mapping organization data for creation", - [] - ], - [ - "log.info", - "StackiqService: SYNC_STEP_10 - Calling createOrganisationInOpenRegister", - [] - ], - [ - "log.error", - "StackiqService: Failed to sync organization with OpenRegister: An OpenRegister organisation is only created for a signed-in user", - { - "objectId": 17, - "exception": "An OpenRegister organisation is only created for a signed-in user", - "file": "(file)", - "line": "(line)", - "trace": "(trace)" - } - ] - ] - }, - "create: create, no id": { - "result": null, - "calls": [ - [ - "log.error", - "StackiqService: No organization UUID provided for creation", - [] - ] - ] - }, - "create: create, signed in": { - "result": "entity:org-1", - "calls": [ - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "organisations.createOrganisation", - "Gemeente A", - "https://a.nl", - true, - "org-1" - ], - [ - "log.info", - "StackiqService: Processing nested contact persons", - { - "organizationUuid": "org-1", - "nestedContactPersonCount": 3 - } - ], - [ - "log.info", - "StackiqService: Added nested contact person username", - { - "username": "a@a.nl", - "contactPersonData": { - "email": "a@a.nl" - } - } - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\ObjectService" - ], - [ - "container.get", - "OCA\\Stackiq\\Service\\SettingsService" - ], - [ - "settings.getVoorzieningenConfig" - ], - [ - "log.warning", - "StackiqService: Missing contactpersoon schema config for username extraction", - [] - ], - [ - "organisation.getUserIds", - [] - ], - [ - "mapper.addUserToOrganisation", - "org-1", - "a@a.nl" - ], - [ - "log.info", - "StackiqService: Created organization in OpenRegister", - { - "organizationUuid": "org-1", - "openRegisterId": "org-1", - "contactPersons": 1 - } - ] - ] - }, - "create: create, organisation service down": { - "result": null, - "calls": [ - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "log.error", - "StackiqService: Error in public createOrganisationInOpenRegister", - { - "error": "organisation service down", - "objectData": { - "id": "org-1", - "name": "Gemeente A", - "website": "https://a.nl", - "beoordeling": "actief", - "contactpersonen": [ - { - "email": "a@a.nl" - }, - { - "naam": "zonder mail" - }, - "c-2" - ] - } - } - ] - ] - }, - "create: create, anonymous, no mapper": { - "result": null, - "calls": [ - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "log.error", - "StackiqService: Error in public createOrganisationInOpenRegister", - { - "error": "An OpenRegister organisation is only created for a signed-in user", - "objectData": { - "id": "org-1", - "name": "Gemeente A", - "website": "https://a.nl", - "beoordeling": "actief", - "contactpersonen": [ - { - "email": "a@a.nl" - }, - { - "naam": "zonder mail" - }, - "c-2" - ] - } - } - ] - ] - }, - "create: create, anonymous": { - "result": null, - "calls": [ - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "log.error", - "StackiqService: Error in public createOrganisationInOpenRegister", - { - "error": "An OpenRegister organisation is only created for a signed-in user", - "objectData": { - "id": "org-1", - "name": "Gemeente A", - "website": "https://a.nl", - "beoordeling": "actief", - "contactpersonen": [ - { - "email": "a@a.nl" - }, - { - "naam": "zonder mail" - }, - "c-2" - ] - } - } - ] - ] - }, - "create: create, forged owner and members": { - "result": "entity:org-1", - "calls": [ - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "organisations.createOrganisation", - "Gemeente A", - "https://a.nl", - true, - "org-1" - ], - [ - "log.info", - "StackiqService: Processing nested contact persons", - { - "organizationUuid": "org-1", - "nestedContactPersonCount": 3 - } - ], - [ - "log.info", - "StackiqService: Added nested contact person username", - { - "username": "a@a.nl", - "contactPersonData": { - "email": "a@a.nl" - } - } - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\ObjectService" - ], - [ - "container.get", - "OCA\\Stackiq\\Service\\SettingsService" - ], - [ - "settings.getVoorzieningenConfig" - ], - [ - "log.warning", - "StackiqService: Missing contactpersoon schema config for username extraction", - [] - ], - [ - "organisation.getUserIds", - [] - ], - [ - "mapper.addUserToOrganisation", - "org-1", - "a@a.nl" - ], - [ - "log.info", - "StackiqService: Created organization in OpenRegister", - { - "organizationUuid": "org-1", - "openRegisterId": "org-1", - "contactPersons": 1 - } - ] - ] - }, - "create: create, creator is a contact": { - "result": "entity:org-1", - "calls": [ - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "organisations.createOrganisation", - "Gemeente A", - "https://a.nl", - true, - "org-1" - ], - [ - "log.info", - "StackiqService: Processing nested contact persons", - { - "organizationUuid": "org-1", - "nestedContactPersonCount": 2 - } - ], - [ - "log.info", - "StackiqService: Added nested contact person username", - { - "username": "signed-in", - "contactPersonData": { - "email": "signed-in" - } - } - ], - [ - "log.info", - "StackiqService: Added nested contact person username", - { - "username": "a@a.nl", - "contactPersonData": { - "email": "a@a.nl" - } - } - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\ObjectService" - ], - [ - "container.get", - "OCA\\Stackiq\\Service\\SettingsService" - ], - [ - "settings.getVoorzieningenConfig" - ], - [ - "log.warning", - "StackiqService: Missing contactpersoon schema config for username extraction", - [] - ], - [ - "organisation.getUserIds", - [] - ], - [ - "mapper.addUserToOrganisation", - "org-1", - "a@a.nl" - ], - [ - "log.info", - "StackiqService: Created organization in OpenRegister", - { - "organizationUuid": "org-1", - "openRegisterId": "org-1", - "contactPersons": 2 - } - ] - ] - }, - "create: create, no mapper": { - "result": null, - "calls": [ - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "container.get", - "OCA\\OpenRegister\\Db\\OrganisationMapper" - ], - [ - "log.error", - "Failed to get OrganisationMapper: no mapper", - [] - ], - [ - "log.error", - "StackiqService: Error in public createOrganisationInOpenRegister", - { - "error": "OpenRegister organisations are not available", - "objectData": { - "id": "org-1", - "name": "Gemeente A", - "website": "https://a.nl", - "beoordeling": "actief", - "contactpersonen": [ - { - "email": "a@a.nl" - }, - { - "naam": "zonder mail" - }, - "c-2" - ] - } - } - ] - ] - }, - "sync: sync, new, organisation service down": { - "result": false, - "calls": [ - [ - "log.info", - "StackiqService: SYNC_STEP_1 - Starting syncOrganizationWithOpenRegister", - { - "objectId": 17, - "objectClass": "class@anonymous" - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_2 - Extracted organization data", - { - "organizationUuid": "org-1", - "objectDataKeys": [ - "id", - "name", - "website", - "beoordeling", - "contactpersonen" - ], - "hasId": true - } - ], - [ - "log.info", - "StackiqService: SYNC_STEP_3 - Getting OrganisationService", - [] - ], - [ - "container.get", - "OCA\\OpenRegister\\Service\\OrganisationService" - ], - [ - "log.error", - "Failed to get OrganisationService: organisation service down", - [] - ], - [ - "log.error", - "StackiqService: SYNC_STEP_3 - OpenRegister OrganisationService not available", - [] - ] - ] - } -}