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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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" } ], [