diff --git a/appinfo/info.xml b/appinfo/info.xml index c01f47ed8..5a6a9a69b 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -237,6 +237,13 @@ Vrij en open source onder de EUPL-1.2-licentie. --> OCA\Dossiq\Repair\RenameDutchSchemaSlugs OCA\Dossiq\Repair\InitializeSettings + + OCA\Dossiq\Repair\ProvisionAssignedGroups OCA\Dossiq\Repair\LoadDefaultZgwMappings OCA\Dossiq\Repair\SeedBezwaarBeroepData OCA\Dossiq\Repair\MigrateWorkflowDefinitions diff --git a/docs/admin/groups.md b/docs/admin/groups.md new file mode 100644 index 000000000..fa0262318 --- /dev/null +++ b/docs/admin/groups.md @@ -0,0 +1,30 @@ +--- +id: groups +title: Groups Dossiq expects +sidebar_position: 5 +description: The Nextcloud groups the shipped case flows assign work to, why Dossiq creates them at install, and what to do when your user backend refuses that. +--- + +# Groups Dossiq expects + +The shipped case flow assigns its behandelaar step to the Nextcloud group `behandelaars`. Dossiq creates that group at install and on every upgrade. The step is idempotent: an existing group is left alone. + +Membership stays yours. Dossiq never adds users to the group. Add your case handlers yourself: + +```bash +occ group:adduser behandelaars +``` + +## When the group is missing + +Without the group, nobody can complete a step assigned to it. The completion signal is refused with "the user who completed the task is not the assignee of the awaiting step". That is deliberate: the gate fails closed rather than letting anyone answer. + +Some user backends refuse group creation, LDAP-only setups for example. Dossiq then logs a warning during install. Create the group in your backend and the flow works without further changes. + +## Which groups + +| Group | Used by | Purpose | +|-------|---------|---------| +| `behandelaars` | shipped case flow, step `task-behandelaar` | case handlers who finish the inhoudelijke voorbereiding | + +A test guards this table's code side: a shipped flow cannot assign work to a group the install does not provision. diff --git a/lib/Repair/LoadDefaultZgwMappings.php b/lib/Repair/LoadDefaultZgwMappings.php index 3cf1363ab..a2c494b66 100644 --- a/lib/Repair/LoadDefaultZgwMappings.php +++ b/lib/Repair/LoadDefaultZgwMappings.php @@ -41,6 +41,9 @@ * @SuppressWarnings(PHPMD.ExcessiveClassLength) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) * @SuppressWarnings(PHPMD.CyclomaticComplexity) + * @SuppressWarnings(PHPMD.ExcessiveClassComplexity) + * + * @spec openspec/specs/zgw-api-mapping/spec.md */ class LoadDefaultZgwMappings implements IRepairStep { /** @@ -71,6 +74,8 @@ public function __construct( * Get the name of this repair step. * * @return string + * + * @spec openspec/specs/zgw-api-mapping/spec.md */ public function getName(): string { return 'Load default ZGW API mapping configurations for Dossiq'; @@ -113,11 +118,26 @@ public function run(IOutput $output): void { // Patch existing mappings that have known bugs (e.g., Twig renders false as ""). $this->patchExistingMappings(defaults: $defaults, output: $output); - // Create default test applicaties via ConsumerMapper. - $this->createDefaultApplicaties(output: $output); + // The two seeding phases below are conveniences, and a repair step + // that THROWS aborts the whole install. On a fresh install the schema + // settings this register was configured with can still be empty, and a + // lookup against an empty schema context throws — so each phase warns + // and continues instead of taking the install down with it. + try { + // Create default test applicaties via ConsumerMapper. + $this->createDefaultApplicaties(output: $output); + } catch (\Throwable $e) { + $output->warning('Could not create default applicaties: ' . $e->getMessage()); + $this->logger->warning('Dossiq: default applicaties seed failed', ['exception' => $e->getMessage()]); + } - // Create default notification channels. - $this->createDefaultKanalen(output: $output); + try { + // Create default notification channels. + $this->createDefaultKanalen(output: $output); + } catch (\Throwable $e) { + $output->warning('Could not create default notification channels: ' . $e->getMessage()); + $this->logger->warning('Dossiq: default kanalen seed failed', ['exception' => $e->getMessage()]); + } $this->logger->info( 'Dossiq: Default ZGW mappings loaded', @@ -1579,6 +1599,16 @@ private function createDefaultKanalen(IOutput $output): void { return; } + // On a fresh install the schema settings can still be empty when this + // step runs; a search against an empty schema context throws and would + // abort the install. Skip by name instead. + if ((string)($channelMapping['sourceRegister'] ?? '') === '' + || (string)($channelMapping['sourceSchema'] ?? '') === '' + ) { + $output->info('Kanaal mapping has no register/schema configured yet. Skipping default channels.'); + return; + } + try { $container = \OC::$server; $objectService = $container->get( diff --git a/lib/Repair/ProvisionAssignedGroups.php b/lib/Repair/ProvisionAssignedGroups.php new file mode 100644 index 000000000..878d5cf07 --- /dev/null +++ b/lib/Repair/ProvisionAssignedGroups.php @@ -0,0 +1,121 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/specs/case-management/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Repair; + +use OCP\IGroupManager; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; + +/** + * Creates the Nextcloud groups the shipped register data assigns work to. + * + * @spec openspec/specs/case-management/spec.md + */ +class ProvisionAssignedGroups implements IRepairStep { + + /** + * Every group the shipped register data assigns steps to. + * + * This list is the provisioning counterpart of the literals in + * lib/Settings/dossiq_register.json; ProvisionAssignedGroupsTest sweeps + * the shipped flows and fails when a group is assigned there that this + * list does not provision, so the two cannot drift apart silently. + * + * @var array + */ + public const ASSIGNED_GROUPS = ['behandelaars']; + + /** + * Constructor. + * + * @param IGroupManager $groupManager Group manager used to provision the groups. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private readonly IGroupManager $groupManager, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Get the repair-step display name. + * + * @return string + * + * @spec openspec/specs/case-management/spec.md + */ + public function getName(): string { + return 'Provision the Nextcloud groups Dossiq\'s shipped flows assign work to'; + }//end getName() + + /** + * Create each missing assigned group. + * + * @param IOutput $output Output sink. + * + * @return void + * + * @spec openspec/specs/case-management/spec.md + */ + public function run(IOutput $output): void { + foreach (self::ASSIGNED_GROUPS as $groupId) { + if ($this->groupManager->groupExists($groupId) === true) { + continue; + } + + $group = $this->groupManager->createGroup($groupId); + if ($group === null) { + // A backend can refuse group creation (e.g. LDAP-only setups). + // That must be loud: without the group the shipped flow's + // completion signal is refused for every actor. + $output->warning( + 'Dossiq: could not create group "' . $groupId . '"; shipped flow steps assigned to it cannot be completed until an admin creates it.' + ); + $this->logger->warning( + 'Dossiq: group provisioning refused by the backend', + ['group' => $groupId] + ); + continue; + } + + $output->info('Dossiq: created group "' . $groupId . '" for shipped flow assignments.'); + $this->logger->info('Dossiq: provisioned assigned group', ['group' => $groupId]); + } + }//end run() +}//end class diff --git a/lib/Repair/SeedDeadlineMonitoringData.php b/lib/Repair/SeedDeadlineMonitoringData.php index d82d19511..52e350f44 100644 --- a/lib/Repair/SeedDeadlineMonitoringData.php +++ b/lib/Repair/SeedDeadlineMonitoringData.php @@ -29,6 +29,7 @@ namespace OCA\Dossiq\Repair; +use OCA\Dossiq\Repair\Support\RunsUnderSystemIdentity; use OCA\Dossiq\Service\DeadlineMonitoringSeedDataService; use OCA\Dossiq\Service\SettingsService; use OCP\Migration\IOutput; @@ -38,9 +39,17 @@ /** * Repair step that seeds termijnbewaking demo data into OpenRegister. * + * Runs under OpenRegister's system identity: a repair step executes during + * `occ upgrade` with no session, and OpenRegister refuses Anonymous writes + * per row. Without the identity every row failed, the failures were counted + * as nothing, and the step reported "0 definities (0 overgeslagen)" as + * success — so no TermijnDefinitie ever existed on a fresh install and no + * termijn timer could arm. + * * @spec openspec/specs/termijnbewaking-schemas/spec.md */ class SeedDeadlineMonitoringData implements IRepairStep { + use RunsUnderSystemIdentity; /** * Constructor. * @@ -84,8 +93,16 @@ public function run(IOutput $output): void { } try { - $result = $this->seedService->seed(); - if (($result['success'] ?? false) === true) { + $result = []; + $this->withSystemIdentity( + objectService: $this->settingsService->getObjectService(), + work: function () use (&$result): void { + $result = $this->seedService->seed(); + } + ); + + $failed = (int)($result['failed'] ?? 0); + if (($result['success'] ?? false) === true && $failed === 0) { $output->info( 'Termijnbewaking seed complete: ' . ((int)($result['definities'] ?? 0)) . ' definities (' @@ -94,10 +111,18 @@ public function run(IOutput $output): void { return; } - $output->warning('Termijnbewaking seed issue: ' . ((string)($result['message'] ?? 'unknown error'))); + // A seed that seeded nothing must not report success-shaped output: + // every failed row is named in the count, so an operator sees a + // broken fresh install instead of "0 definities (0 overgeslagen)". + $output->warning( + 'Termijnbewaking seed issue: ' + . ((int)($result['definities'] ?? 0)) . ' definities, ' + . $failed . ' rijen geweigerd (' + . ((string)($result['message'] ?? 'per-row failures, see the log')) . ')' + ); } catch (\Throwable $e) { $output->warning('Could not seed termijnbewaking data: ' . $e->getMessage()); $this->logger->error('Dossiq termijnbewaking seed failed', ['exception' => $e->getMessage()]); - } + }//end try }//end run() }//end class diff --git a/lib/Repair/SeedVerwerkingsactiviteiten.php b/lib/Repair/SeedVerwerkingsactiviteiten.php index c93fdef47..311e64da7 100644 --- a/lib/Repair/SeedVerwerkingsactiviteiten.php +++ b/lib/Repair/SeedVerwerkingsactiviteiten.php @@ -42,6 +42,7 @@ use OCP\Migration\IRepairStep; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use RuntimeException; /** * Seeds the dossiq verwerkingsactiviteiten catalogue into OpenRegister (draft, upsert-by-code). @@ -186,29 +187,65 @@ private function loadCatalogue(): array { * @return void */ private function hydrate(object $entity, array $definition): void { + // OpenRegister renamed the entity's Dutch columns to English (naam -> + // name, beschrijving -> description, ...). QBMapper entities implement + // setters via __call over their DECLARED properties, so calling the + // old setter throws "naam is not a valid attribute" — which is exactly + // how all 7 catalogue rows failed on every fresh install. Each field + // therefore lists its candidate entity properties, newest first, and + // the one the deployed entity actually declares wins. $stringFields = [ - 'name' => 'setNaam', - 'beschrijving' => 'setBeschrijving', - 'doelbinding' => 'setDoelbinding', - 'rechtsgrond' => 'setRechtsgrond', - 'bewaartermijn' => 'setBewaartermijn', + 'name' => ['name', 'naam'], + 'beschrijving' => ['description', 'beschrijving'], + 'doelbinding' => ['purpose', 'doelbinding'], + 'rechtsgrond' => ['legalBasis', 'rechtsgrond'], + 'bewaartermijn' => ['retentionPeriod', 'bewaartermijn'], ]; - foreach ($stringFields as $field => $setter) { + foreach ($stringFields as $field => $candidates) { if (isset($definition[$field]) === true && is_string($definition[$field]) === true) { - $entity->{$setter}($definition[$field]); + $this->setFirstDeclared(entity: $entity, candidates: $candidates, value: $definition[$field]); } } $arrayFields = [ - 'categorieenBetrokkenen' => 'setCategorieenBetrokkenen', - 'categorieenPersoonsgegevens' => 'setCategorieenPersoonsgegevens', - 'ontvangers' => 'setOntvangers', + 'categorieenBetrokkenen' => ['dataSubjectCategories', 'categorieenBetrokkenen'], + 'categorieenPersoonsgegevens' => ['personalDataCategories', 'categorieenPersoonsgegevens'], + 'ontvangers' => ['recipients', 'ontvangers'], ]; - foreach ($arrayFields as $field => $setter) { + foreach ($arrayFields as $field => $candidates) { if (isset($definition[$field]) === true && is_array($definition[$field]) === true) { - $entity->{$setter}($definition[$field]); + $this->setFirstDeclared(entity: $entity, candidates: $candidates, value: $definition[$field]); } } }//end hydrate() + + /** + * Set the first property the deployed entity actually declares. + * + * `method_exists()` cannot answer this (the setters are magic __call), so + * the DECLARED PROPERTY decides. A value none of the candidates fit is + * loud: seeding a catalogue row that silently loses its purpose or + * retention period would ship an incomplete verwerkingsregister. + * + * @param object $entity OR Verwerkingsactiviteit entity. + * @param array $candidates Property names, newest first. + * @param string|array $value The value to set. + * + * @return void + * + * @throws RuntimeException When no candidate property is declared. + */ + private function setFirstDeclared(object $entity, array $candidates, string|array $value): void { + foreach ($candidates as $property) { + if (property_exists($entity, $property) === true) { + $entity->{'set' . ucfirst($property)}($value); + return; + } + } + + throw new RuntimeException( + 'The deployed Verwerkingsactiviteit entity declares none of: ' . implode(', ', $candidates) + ); + }//end setFirstDeclared() }//end class diff --git a/lib/Service/Besluitvorming/TemplateBundleSeeder.php b/lib/Service/Besluitvorming/TemplateBundleSeeder.php index f56392971..6604843a4 100644 --- a/lib/Service/Besluitvorming/TemplateBundleSeeder.php +++ b/lib/Service/Besluitvorming/TemplateBundleSeeder.php @@ -94,23 +94,11 @@ public function seedBundle( 'parafeerroute' => 0, ]; - $childData = [ - 'statusTypes' => (array)($caseTypeData['statusTypes'] ?? []), - 'roleTypes' => (array)($caseTypeData['roleTypes'] ?? []), - 'propertyDefinitions' => (array)($caseTypeData['propertyDefinitions'] ?? []), - 'documentTypes' => (array)($caseTypeData['documentTypes'] ?? []), - 'resultTypes' => (array)($caseTypeData['resultTypes'] ?? []), - ]; - $workflowData = ($caseTypeData['workflowTemplate'] ?? null); - - unset( - $caseTypeData['statusTypes'], - $caseTypeData['roleTypes'], - $caseTypeData['propertyDefinitions'], - $caseTypeData['documentTypes'], - $caseTypeData['resultTypes'], - $caseTypeData['workflowTemplate'], - ); + $split = $this->splitBundle(caseTypeData: $caseTypeData); + $caseTypeData = $split['caseTypeData']; + $childData = $split['childData']; + $workflowData = $split['workflowData']; + $initialStatusName = $split['initialStatusName']; $caseType = $this->createObject( objectService: $objectService, @@ -134,6 +122,17 @@ public function seedBundle( counts: $counts, ); + $this->linkInitialStatus( + objectService: $objectService, + register: $register, + schema: $schemas['caseType'], + caseTypeId: $caseTypeId, + caseTypeData: $caseTypeData, + initialStatusName: $initialStatusName, + statusNameMap: $nameMaps['statusTypes'], + slug: $slug, + ); + $this->seedWorkflowTemplate( objectService: $objectService, register: $register, @@ -248,6 +247,112 @@ private function seedCaseTypeChildren( return $nameMaps; }//end seedCaseTypeChildren() + /** + * Split a bundle's caseType payload from the collections seeded after it. + * + * The child collections and the workflow are nested inside the caseType in + * the bundle file, but are separate OpenRegister objects that can only be + * created once the caseType they link to exists. `initialStatusName` is + * pulled out for the same reason in reverse: the caseType names its + * initial status, but the statusType it names does not exist yet, so the + * link is written back after the children are seeded. + * + * @param array $caseTypeData The bundle's caseType payload. + * + * @return array{caseTypeData: array, childData: array>, + * workflowData: mixed, initialStatusName: string} The split payload. + * + * @spec openspec/specs/besluitvorming-workflow/spec.md + */ + private function splitBundle(array $caseTypeData): array { + $childData = [ + 'statusTypes' => (array)($caseTypeData['statusTypes'] ?? []), + 'roleTypes' => (array)($caseTypeData['roleTypes'] ?? []), + 'propertyDefinitions' => (array)($caseTypeData['propertyDefinitions'] ?? []), + 'documentTypes' => (array)($caseTypeData['documentTypes'] ?? []), + 'resultTypes' => (array)($caseTypeData['resultTypes'] ?? []), + ]; + $workflowData = ($caseTypeData['workflowTemplate'] ?? null); + $initialStatusName = trim((string)($caseTypeData['initialStatusName'] ?? '')); + + unset( + $caseTypeData['statusTypes'], + $caseTypeData['roleTypes'], + $caseTypeData['propertyDefinitions'], + $caseTypeData['documentTypes'], + $caseTypeData['resultTypes'], + $caseTypeData['workflowTemplate'], + $caseTypeData['initialStatusName'], + ); + + return [ + 'caseTypeData' => $caseTypeData, + 'childData' => $childData, + 'workflowData' => $workflowData, + 'initialStatusName' => $initialStatusName, + ]; + }//end splitBundle() + + /** + * Write the caseType's initialStatus link once the statusTypes exist. + * + * The bundle can only name the initial status (the statusTypes are created + * AFTER the caseType they belong to), so this update resolves the name via + * the freshly-seeded name map and writes the id back. A bundle that names + * no initial status, or names one that did not seed, is logged loudly: the + * cost is a case born statusless through the API. + * + * @param object $objectService The OpenRegister ObjectService. + * @param string $register The register slug. + * @param string $schema The caseType schema id. + * @param string $caseTypeId The freshly-created caseType id. + * @param array $caseTypeData The caseType payload as created. + * @param string $initialStatusName The status name the bundle declared. + * @param array $statusNameMap Map of statusType name => id. + * @param string $slug The template slug (for logging). + * + * @return void + * + * @spec openspec/specs/besluitvorming-workflow/spec.md + */ + private function linkInitialStatus( + object $objectService, + string $register, + string $schema, + string $caseTypeId, + array $caseTypeData, + string $initialStatusName, + array $statusNameMap, + string $slug, + ): void { + if ($caseTypeId === '' || $schema === '') { + return; + } + + $statusId = ($statusNameMap[$initialStatusName] ?? ''); + if ($initialStatusName === '' || $statusId === '') { + $this->logger->warning( + 'Dossiq: besluitvorming template names no resolvable initial status; API-created cases of this type are born statusless', + ['slug' => $slug, 'initialStatusName' => $initialStatusName], + ); + return; + } + + try { + $objectService->saveObject( + register: $register, + schema: $schema, + object: array_merge($caseTypeData, ['initialStatus' => $statusId]), + uuid: $caseTypeId, + ); + } catch (\Throwable $e) { + $this->logger->warning( + 'Dossiq: could not write the caseType initialStatus link', + ['slug' => $slug, 'exception' => $e->getMessage()], + ); + } + }//end linkInitialStatus() + /** * Seed the workflow template, resolving its name references first. * diff --git a/lib/Service/BesluitvormingTemplateService.php b/lib/Service/BesluitvormingTemplateService.php index ba97ecd8b..fc8ce0a1b 100644 --- a/lib/Service/BesluitvormingTemplateService.php +++ b/lib/Service/BesluitvormingTemplateService.php @@ -123,6 +123,7 @@ public function activate(string $slug): array { } $bundle = $this->loadBundle(slug: $slug); + $this->warnOnStepLevelActions(bundle: $bundle, slug: $slug); $objectService = $this->settingsService->getObjectService(); if ($objectService === null) { @@ -209,6 +210,50 @@ private function loadBundle(string $slug): array { return $decoded; }//end loadBundle() + /** + * Warn loudly when a bundle declares actions where the engine never reads. + * + * The transition engine dispatches ONLY the automaticActions declared on a + * TRANSITION (TransitionSpecReader::extractActions() is its sole action + * source). An action declared on a step is silently inert: the transition + * succeeds, `dispatchedActions` stays empty and nothing is logged — which + * is exactly how a shipped besluitvormingActivate never armed the + * parafering seam on any fresh install. This makes the no-op loud at + * activation time. + * + * @param array $bundle The decoded template bundle. + * @param string $slug The template slug (for logging). + * + * @return void + * + * @spec openspec/specs/besluitvorming-workflow/spec.md + */ + private function warnOnStepLevelActions(array $bundle, string $slug): void { + $workflow = (array)(((array)($bundle['caseType'] ?? []))['workflowTemplate'] ?? []); + foreach ((array)($workflow['steps'] ?? []) as $index => $step) { + if (is_array($step) === false) { + continue; + } + + foreach (['automaticActions', 'actions'] as $key) { + if (isset($step[$key]) === true && is_array($step[$key]) === true && $step[$key] !== []) { + $this->logger->warning( + 'Dossiq: besluitvorming template declares automatic actions on a STEP, a position the ' + . 'transition engine never reads; these actions will never run. Move them to the ' + . 'transition entering the step\'s status.', + [ + 'slug' => $slug, + 'step' => (int)$index, + 'statusName' => (string)($step['statusName'] ?? ''), + 'key' => $key, + 'app' => Application::APP_ID, + ], + ); + } + } + } + }//end warnOnStepLevelActions() + /** * Resolve the schema ids needed to seed a bundle. * diff --git a/lib/Service/DeadlineMonitoringSeedDataService.php b/lib/Service/DeadlineMonitoringSeedDataService.php index 29f739f94..272226def 100644 --- a/lib/Service/DeadlineMonitoringSeedDataService.php +++ b/lib/Service/DeadlineMonitoringSeedDataService.php @@ -35,6 +35,8 @@ /** * Seeds three demo TermijnDefinitie rows into OpenRegister. + * + * @spec openspec/specs/termijnbewaking-schemas/spec.md */ class DeadlineMonitoringSeedDataService { use SearchesObjects; @@ -113,7 +115,7 @@ private function insertDefinitions( array $data, array $existingIds, ): array { - $counts = ['definities' => 0, 'skipped' => 0]; + $counts = ['definities' => 0, 'skipped' => 0, 'failed' => 0]; foreach (($data['termijnDefinities'] ?? []) as $row) { $rowId = (string)($row['id'] ?? ''); @@ -126,6 +128,11 @@ private function insertDefinitions( $this->saveObjectAsArray(objectService: $objectService, register: $register, schema: $schema, object: $row); $counts['definities']++; } catch (\Throwable $e) { + // COUNTED, not merely logged. A refused row that only logs + // leaves the summary success-shaped, which is how a fresh + // install shipped zero TermijnDefinities while reporting + // "0 definities (0 overgeslagen)" as if that were fine. + $counts['failed']++; $this->logger->warning( 'Dossiq termijnbewaking seed: row failed', ['id' => $rowId, 'error' => $e->getMessage()] diff --git a/lib/Service/Parafeer/ParaferingDelegationService.php b/lib/Service/Parafeer/ParaferingDelegationService.php index 9a384ed68..d2745f6cc 100644 --- a/lib/Service/Parafeer/ParaferingDelegationService.php +++ b/lib/Service/Parafeer/ParaferingDelegationService.php @@ -70,25 +70,53 @@ class ParaferingDelegationService { /** * Local step fields that map onto an approval-route step unchanged. * + * `actorType` is deliberately NOT here: the two apps speak different + * actor vocabularies, so it is translated in mapStep() instead of copied. + * * @var array */ - private const STEP_FIELDS = ['order', 'actor', 'actorType', 'mandatory', 'label']; + private const STEP_FIELDS = ['order', 'actor', 'mandatory', 'label']; /** * Local parafeerroute step types, mapped to the approval-route vocabulary. * - * A step type that is not in this map travels as `endorsement`, which is - * what an unrecognised signing step is: somebody has to sign, and no - * stronger claim is made about what their signature means. + * `advice` is the parafeerroute schema's own enum spelling + * (lib/Settings/dossiq_register.json); `advies` is kept for rows written + * before that enum settled. A step type that is not in this map travels as + * `endorsement`, which is what an unrecognised signing step is: somebody + * has to sign, and no stronger claim is made about what their signature + * means. * * @var array */ private const STAGE_TYPES = [ + 'advice' => 'advisory', 'advies' => 'advisory', 'parafering' => 'endorsement', 'accordering' => 'decisive', ]; + /** + * Local actorType values, mapped to the approval-route vocabulary. + * + * The decision app's ApprovalRoute step schema accepts ONLY + * `person` | `body` | `role`, and its store REFUSES the whole route on an + * unknown value — which is how every shipped demo route with a `group` + * actor came back "not handled" on a fresh install. A Nextcloud group, + * like a role, is resolved by the consuming context at completion time + * (the assignee gate checks membership), so `group` travels as `role`. + * Values already in the decision app's vocabulary pass through unchanged. + * + * @var array + */ + private const ACTOR_TYPES = [ + 'user' => 'person', + 'group' => 'role', + 'role' => 'role', + 'person' => 'person', + 'body' => 'body', + ]; + /** * Constructor. * @@ -271,6 +299,11 @@ private function mapStep(array $step, int $position): array { } } + $actorType = trim((string)($step['actorType'] ?? '')); + if ($actorType !== '') { + $mapped['actorType'] = (self::ACTOR_TYPES[$actorType] ?? 'role'); + } + // A step with no `order` would collapse the sequence on the other side, // where order IS the sign-off sequence. Losing it does not produce a // broken route; it produces a plausible one in the wrong order, which is diff --git a/lib/Settings/dossiq_mock_register.json b/lib/Settings/dossiq_mock_register.json index b26c02f41..719c1e229 100644 --- a/lib/Settings/dossiq_mock_register.json +++ b/lib/Settings/dossiq_mock_register.json @@ -8946,7 +8946,7 @@ "to_amount": { "type": "number", "title": "Up To Amount", - "description": "Upper bedrag limit; null means unlimited" + "description": "Upper bedrag limit; omit the property for unlimited" }, "caseTypes": { "type": "array", diff --git a/lib/Settings/register.d/30-beschikking.json b/lib/Settings/register.d/30-beschikking.json index cfabbf32f..117f08d63 100644 --- a/lib/Settings/register.d/30-beschikking.json +++ b/lib/Settings/register.d/30-beschikking.json @@ -317,11 +317,7 @@ "type": "object", "properties": { "level": { "type": "string", "title": "Level", "description": "Approval level (e.g. consulent, afdelingsmanager, directeur)" }, - "to_amount": { - "type": [ - "number", - "null" - ], "title": "Up To Amount", "description": "Upper bedrag limit; null means unlimited" }, + "to_amount": { "type": "number", "title": "Up To Amount", "description": "Upper bedrag limit; omit the property for unlimited" }, "caseTypes": { "type": "array", "title": "Case Types", "description": "Case types for which this authorization level applies", "items": { "type": "string" } }, "decisionTypes": { "type": "array", "title": "Decision Types", "description": "Decision types for which this authorization level applies", "items": { "type": "string" } } } @@ -345,7 +341,7 @@ "mandateGroups": [ { "level": "consulent", "to_amount": 5000, "caseTypes": ["wmo-melding"], "decisionTypes": ["toekenning"] }, { "level": "afdelingsmanager", "to_amount": 25000, "caseTypes": ["wmo-melding"], "decisionTypes": ["toekenning", "afwijzing"] }, - { "level": "directeur", "to_amount": null, "caseTypes": ["wmo-melding"], "decisionTypes": ["toekenning", "afwijzing", "wijziging"] } + { "level": "directeur", "caseTypes": ["wmo-melding"], "decisionTypes": ["toekenning", "afwijzing", "wijziging"] } ] }, { diff --git a/lib/Settings/templates/bvw-college-besluit.json b/lib/Settings/templates/bvw-college-besluit.json index 84bc849ef..2964872c0 100644 --- a/lib/Settings/templates/bvw-college-besluit.json +++ b/lib/Settings/templates/bvw-college-besluit.json @@ -4,6 +4,7 @@ "deprecationNote": "Decision types are now managed by decidesk (procest-delegate-contract-decision). These templates are kept for historical read access until sunset.", "caseType": { "identifier": "bvw-college-besluit", + "initialStatusName": "Voorstel opstellen", "title": "College-besluit", "description": "Formeel besluit van het college van burgemeester en wethouders", "purpose": "Vaststellen van collegebesluiten conform het collegeprogramma", @@ -46,9 +47,9 @@ { "name": "Bekendmakingsbewijs", "description": "Bevestiging van publicatie via DROP/LVBB", "isRequired": true } ], "resultTypes": [ - { "name": "Besluit genomen", "description": "Besluit is genomen en vastgelegd", "archivalPeriod": "P20Y", "archivalAction": "keep" }, - { "name": "Aangehouden", "description": "Besluit is aangehouden voor een volgende vergadering", "archivalPeriod": "P5Y", "archivalAction": "destroy" }, - { "name": "Ingetrokken", "description": "Voorstel is ingetrokken", "archivalPeriod": "P5Y", "archivalAction": "destroy" } + { "name": "Besluit genomen", "description": "Besluit is genomen en vastgelegd", "archivalPeriod": "P20Y", "archivalAction": "bewaren" }, + { "name": "Aangehouden", "description": "Besluit is aangehouden voor een volgende vergadering", "archivalPeriod": "P5Y", "archivalAction": "vernietigen" }, + { "name": "Ingetrokken", "description": "Voorstel is ingetrokken", "archivalPeriod": "P5Y", "archivalAction": "vernietigen" } ], "workflowTemplate": { "title": "College-besluit workflow v1", @@ -59,17 +60,20 @@ "steps": [ { "title": "Voorstel opstellen", "statusName": "Voorstel opstellen", "order": 1, "isRequired": true, "description": "Ambtenaar stelt het collegeadvies op" }, { "title": "Ambtelijk advies", "statusName": "Ambtelijk advies", "order": 2, "isRequired": true, "description": "Inhoudelijk advies door beleidsadviseur" }, - { "title": "Parafering", "statusName": "Parafering", "order": 3, "isRequired": true, "description": "Sequentiele goedkeuring", "automaticActions": [{ "type": "besluitvormingActivate" }] }, + { "title": "Parafering", "statusName": "Parafering", "order": 3, "isRequired": true, "description": "Sequentiele goedkeuring" }, { "title": "Gereed voor agendering", "statusName": "Gereed voor agendering", "order": 4, "isRequired": true, "description": "Klaar voor agendacompilatie" }, { "title": "Geagendeerd", "statusName": "Geagendeerd", "order": 5, "isRequired": true, "description": "Op de agenda gezet" }, { "title": "Vergadering", "statusName": "Vergadering", "order": 6, "isRequired": true, "description": "Behandeling tijdens de vergadering" }, { "title": "Besluit genomen", "statusName": "Besluit genomen", "order": 7, "isRequired": true, "description": "Besluit vastgelegd" }, - { "title": "Bekendmaking", "statusName": "Bekendmaking", "order": 8, "isRequired": true, "description": "Publicatie via DROP/LVBB", "automaticActions": [{ "type": "besluitvormingPublish" }] }, + { "title": "Bekendmaking", "statusName": "Bekendmaking", "order": 8, "isRequired": true, "description": "Publicatie via DROP/LVBB" }, { "title": "Gearchiveerd", "statusName": "Gearchiveerd", "order": 9, "isRequired": true, "description": "Dossier gearchiveerd", "isFinal": true } ], "transitions": [ { "fromStatusName": "Voorstel opstellen", "toStatusName": "Ambtelijk advies", "label": "Naar advies" }, - { "fromStatusName": "Ambtelijk advies", "toStatusName": "Parafering", "label": "Start parafering" }, + { + "fromStatusName": "Ambtelijk advies", "toStatusName": "Parafering", "label": "Start parafering", + "automaticActions": [{ "type": "besluitvormingActivate" }] + }, { "fromStatusName": "Parafering", "toStatusName": "Gereed voor agendering", "label": "Parafering compleet", "guards": [{ "type": "requiredField", "config": { "field": "paraferingCompleet" } }] @@ -82,7 +86,8 @@ }, { "fromStatusName": "Besluit genomen", "toStatusName": "Bekendmaking", "label": "Bekendmaken", - "guards": [{ "type": "requiredDocument", "config": { "documentType": "Besluitdocument" } }] + "guards": [{ "type": "requiredDocument", "config": { "documentType": "Besluitdocument" } }], + "automaticActions": [{ "type": "besluitvormingPublish" }] }, { "fromStatusName": "Bekendmaking", "toStatusName": "Gearchiveerd", "label": "Archiveren", diff --git a/lib/Settings/templates/bvw-mandaatbesluit.json b/lib/Settings/templates/bvw-mandaatbesluit.json index 427c1097b..2c6995122 100644 --- a/lib/Settings/templates/bvw-mandaatbesluit.json +++ b/lib/Settings/templates/bvw-mandaatbesluit.json @@ -4,6 +4,7 @@ "deprecationNote": "Decision types are now managed by decidesk (procest-delegate-contract-decision). These templates are kept for historical read access until sunset.", "caseType": { "identifier": "bvw-mandaatbesluit", + "initialStatusName": "Voorstel opstellen", "title": "Mandaatbesluit", "description": "Besluit genomen op basis van ambtelijk of politiek mandaat", "purpose": "Vastleggen van besluiten binnen gedelegeerde bevoegdheden", @@ -43,9 +44,9 @@ { "name": "Besluitdocument", "description": "Het ondertekende mandaatbesluit", "isRequired": true } ], "resultTypes": [ - { "name": "Besluit genomen", "description": "Mandaatbesluit is genomen en vastgelegd", "archivalPeriod": "P10Y", "archivalAction": "destroy" }, - { "name": "Aangehouden", "description": "Besluit is aangehouden", "archivalPeriod": "P5Y", "archivalAction": "destroy" }, - { "name": "Ingetrokken", "description": "Voorstel is ingetrokken", "archivalPeriod": "P5Y", "archivalAction": "destroy" } + { "name": "Besluit genomen", "description": "Mandaatbesluit is genomen en vastgelegd", "archivalPeriod": "P10Y", "archivalAction": "vernietigen" }, + { "name": "Aangehouden", "description": "Besluit is aangehouden", "archivalPeriod": "P5Y", "archivalAction": "vernietigen" }, + { "name": "Ingetrokken", "description": "Voorstel is ingetrokken", "archivalPeriod": "P5Y", "archivalAction": "vernietigen" } ], "workflowTemplate": { "title": "Mandaatbesluit workflow v1", @@ -56,7 +57,7 @@ "steps": [ { "title": "Voorstel opstellen", "statusName": "Voorstel opstellen", "order": 1, "isRequired": true, "description": "Steller stelt het DT-advies op" }, { "title": "Ambtelijk advies", "statusName": "Ambtelijk advies", "order": 2, "isRequired": true, "description": "Inhoudelijk advies" }, - { "title": "Parafering", "statusName": "Parafering", "order": 3, "isRequired": true, "description": "Verkorte parafeerroute", "automaticActions": [{ "type": "besluitvormingActivate" }] }, + { "title": "Parafering", "statusName": "Parafering", "order": 3, "isRequired": true, "description": "Verkorte parafeerroute" }, { "title": "Gereed voor agendering", "statusName": "Gereed voor agendering", "order": 4, "isRequired": true, "description": "Klaar voor mandaatbesluit" }, { "title": "Geagendeerd", "statusName": "Geagendeerd", "order": 5, "isRequired": true, "description": "Opgevoerd voor besluit" }, { "title": "Vergadering", "statusName": "Vergadering", "order": 6, "isRequired": true, "description": "Behandeling binnen mandaat" }, @@ -65,7 +66,10 @@ ], "transitions": [ { "fromStatusName": "Voorstel opstellen", "toStatusName": "Ambtelijk advies", "label": "Naar advies" }, - { "fromStatusName": "Ambtelijk advies", "toStatusName": "Parafering", "label": "Start parafering" }, + { + "fromStatusName": "Ambtelijk advies", "toStatusName": "Parafering", "label": "Start parafering", + "automaticActions": [{ "type": "besluitvormingActivate" }] + }, { "fromStatusName": "Parafering", "toStatusName": "Gereed voor agendering", "label": "Parafering compleet", "guards": [{ "type": "requiredField", "config": { "field": "paraferingCompleet" } }] diff --git a/lib/Settings/templates/bvw-raadsbesluit.json b/lib/Settings/templates/bvw-raadsbesluit.json index 70c267f4d..401e95559 100644 --- a/lib/Settings/templates/bvw-raadsbesluit.json +++ b/lib/Settings/templates/bvw-raadsbesluit.json @@ -4,6 +4,7 @@ "deprecationNote": "Decision types are now managed by decidesk (procest-delegate-contract-decision). These templates are kept for historical read access until sunset.", "caseType": { "identifier": "bvw-raadsbesluit", + "initialStatusName": "Voorstel opstellen", "title": "Raadsbesluit", "description": "Formeel besluit van de gemeenteraad, inclusief moties en amendementen", "purpose": "Vaststellen van raadsbesluiten en verordeningen", @@ -47,9 +48,9 @@ { "name": "Bekendmakingsbewijs", "description": "Bevestiging van publicatie via DROP/LVBB", "isRequired": true } ], "resultTypes": [ - { "name": "Besluit genomen", "description": "Raadsbesluit is genomen en vastgelegd", "archivalPeriod": "P20Y", "archivalAction": "keep" }, - { "name": "Aangehouden", "description": "Besluit is aangehouden voor een volgende vergadering", "archivalPeriod": "P5Y", "archivalAction": "destroy" }, - { "name": "Ingetrokken", "description": "Voorstel is ingetrokken", "archivalPeriod": "P5Y", "archivalAction": "destroy" } + { "name": "Besluit genomen", "description": "Raadsbesluit is genomen en vastgelegd", "archivalPeriod": "P20Y", "archivalAction": "bewaren" }, + { "name": "Aangehouden", "description": "Besluit is aangehouden voor een volgende vergadering", "archivalPeriod": "P5Y", "archivalAction": "vernietigen" }, + { "name": "Ingetrokken", "description": "Voorstel is ingetrokken", "archivalPeriod": "P5Y", "archivalAction": "vernietigen" } ], "workflowTemplate": { "title": "Raadsbesluit workflow v1", @@ -60,17 +61,20 @@ "steps": [ { "title": "Voorstel opstellen", "statusName": "Voorstel opstellen", "order": 1, "isRequired": true, "description": "Steller stelt het raadsvoorstel op" }, { "title": "Ambtelijk advies", "statusName": "Ambtelijk advies", "order": 2, "isRequired": true, "description": "Inhoudelijk advies" }, - { "title": "Parafering", "statusName": "Parafering", "order": 3, "isRequired": true, "description": "Sequentiele goedkeuring inclusief griffier", "automaticActions": [{ "type": "besluitvormingActivate" }] }, + { "title": "Parafering", "statusName": "Parafering", "order": 3, "isRequired": true, "description": "Sequentiele goedkeuring inclusief griffier" }, { "title": "Gereed voor agendering", "statusName": "Gereed voor agendering", "order": 4, "isRequired": true, "description": "Klaar voor agendacompilatie" }, { "title": "Geagendeerd", "statusName": "Geagendeerd", "order": 5, "isRequired": true, "description": "Op de raadsagenda gezet" }, { "title": "Vergadering", "statusName": "Vergadering", "order": 6, "isRequired": true, "description": "Behandeling tijdens de raadsvergadering" }, { "title": "Besluit genomen", "statusName": "Besluit genomen", "order": 7, "isRequired": true, "description": "Raadsbesluit vastgelegd" }, - { "title": "Bekendmaking", "statusName": "Bekendmaking", "order": 8, "isRequired": true, "description": "Publicatie via DROP/LVBB", "automaticActions": [{ "type": "besluitvormingPublish" }] }, + { "title": "Bekendmaking", "statusName": "Bekendmaking", "order": 8, "isRequired": true, "description": "Publicatie via DROP/LVBB" }, { "title": "Gearchiveerd", "statusName": "Gearchiveerd", "order": 9, "isRequired": true, "description": "Dossier gearchiveerd", "isFinal": true } ], "transitions": [ { "fromStatusName": "Voorstel opstellen", "toStatusName": "Ambtelijk advies", "label": "Naar advies" }, - { "fromStatusName": "Ambtelijk advies", "toStatusName": "Parafering", "label": "Start parafering" }, + { + "fromStatusName": "Ambtelijk advies", "toStatusName": "Parafering", "label": "Start parafering", + "automaticActions": [{ "type": "besluitvormingActivate" }] + }, { "fromStatusName": "Parafering", "toStatusName": "Gereed voor agendering", "label": "Parafering compleet", "guards": [{ "type": "requiredField", "config": { "field": "paraferingCompleet" } }] @@ -83,7 +87,8 @@ }, { "fromStatusName": "Besluit genomen", "toStatusName": "Bekendmaking", "label": "Bekendmaken", - "guards": [{ "type": "requiredDocument", "config": { "documentType": "Besluitdocument" } }] + "guards": [{ "type": "requiredDocument", "config": { "documentType": "Besluitdocument" } }], + "automaticActions": [{ "type": "besluitvormingPublish" }] }, { "fromStatusName": "Bekendmaking", "toStatusName": "Gearchiveerd", "label": "Archiveren", diff --git a/tests/Stubs/Db/Verwerkingsactiviteit.php b/tests/Stubs/Db/Verwerkingsactiviteit.php index 41965172e..a2f121e90 100644 --- a/tests/Stubs/Db/Verwerkingsactiviteit.php +++ b/tests/Stubs/Db/Verwerkingsactiviteit.php @@ -5,8 +5,12 @@ * * Minimal surface needed by dossiq unit tests: the catalogue seed repair * step (SeedVerwerkingsactiviteiten) sets the descriptive AVG art. 30 - * fields and reads code/status. Mirrors the real OR entity's setters and - * its rechtsgrond vocabulary so vocabulary assertions stay honest. + * fields and reads code/status. Mirrors the real OR entity's DECLARED + * PROPERTIES (English, post Dutch-column rename) and refuses an undeclared + * attribute exactly like a QBMapper entity's magic __call does — the + * earlier setter-sink stub accepted setNaam() happily while the real + * entity threw "naam is not a valid attribute", which is how 7 failing + * seed rows hid under a green suite. * * @category Stub * @package OCA\OpenRegister\Db @@ -29,19 +33,19 @@ * Stub of OpenRegister's Verwerkingsactiviteit entity for unit tests. * * @method void setCode(?string $code) - * @method void setNaam(?string $name) - * @method void setBeschrijving(?string $description) - * @method void setDoelbinding(?string $doelbinding) - * @method void setRechtsgrond(?string $rechtsgrond) - * @method void setBewaartermijn(?string $retentionPeriod) + * @method void setName(?string $name) + * @method void setDescription(?string $description) + * @method void setPurpose(?string $purpose) + * @method void setLegalBasis(?string $legalBasis) + * @method void setRetentionPeriod(?string $retentionPeriod) * @method void setStatus(?string $status) - * @method void setCategorieenBetrokkenen(?array $categories) - * @method void setCategorieenPersoonsgegevens(?array $categories) - * @method void setOntvangers(?array $ontvangers) + * @method void setDataSubjectCategories(?array $categories) + * @method void setPersonalDataCategories(?array $categories) + * @method void setRecipients(?array $recipients) * @method string|null getCode() - * @method string|null getNaam() - * @method string|null getDoelbinding() - * @method string|null getRechtsgrond() + * @method string|null getName() + * @method string|null getPurpose() + * @method string|null getLegalBasis() * @method string|null getStatus() */ class Verwerkingsactiviteit { @@ -67,14 +71,36 @@ class Verwerkingsactiviteit { public const STATUS_VOCABULARY = ['draft', 'published', 'archived']; /** - * Captured field values (setter sink for assertions). - * - * @var array + * Declared properties, mirroring the real OR entity's English columns. */ - private array $fields = []; + protected ?string $uuid = null; + + protected ?string $code = null; + + protected ?string $name = null; + + protected ?string $description = null; + + protected ?string $purpose = null; + + protected ?string $legalBasis = null; + + protected ?array $dataSubjectCategories = null; + + protected ?array $personalDataCategories = null; + + protected ?string $retentionPeriod = null; + + protected ?array $recipients = null; + + protected ?string $status = null; /** - * Magic setter/getter sink recording every set*() and answering get*(). + * Magic setter/getter over DECLARED properties only, like QBMapper's. + * + * An undeclared attribute throws the same way the real entity does, so a + * seeder calling a renamed setter fails here instead of only in + * production. * * @param string $name Method name. * @param array $args Arguments. @@ -82,24 +108,30 @@ class Verwerkingsactiviteit { * @return mixed */ public function __call(string $name, array $args) { - if (str_starts_with($name, 'set') === true) { - $this->fields[lcfirst(substr($name, 3))] = ($args[0] ?? null); - return null; - } + $property = lcfirst(substr($name, 3)); + + if (str_starts_with($name, 'set') === true || str_starts_with($name, 'get') === true) { + if (property_exists($this, $property) === false) { + throw new \BadFunctionCallException($property . ' is not a valid attribute'); + } + + if (str_starts_with($name, 'set') === true) { + $this->{$property} = ($args[0] ?? null); + return null; + } - if (str_starts_with($name, 'get') === true) { - return ($this->fields[lcfirst(substr($name, 3))] ?? null); + return $this->{$property}; } throw new \BadMethodCallException($name); }//end __call() /** - * All captured fields (test accessor). + * All declared fields (test accessor). * * @return array */ public function toArray(): array { - return $this->fields; + return get_object_vars($this); }//end toArray() }//end class diff --git a/tests/Unit/Repair/ProvisionAssignedGroupsTest.php b/tests/Unit/Repair/ProvisionAssignedGroupsTest.php new file mode 100644 index 000000000..da9e1e3c9 --- /dev/null +++ b/tests/Unit/Repair/ProvisionAssignedGroupsTest.php @@ -0,0 +1,161 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\Dossiq\Tests\Unit\Repair + * @author Conduction B.V. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @link https://github.com/ConductionNL/dossiq + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Repair; + +use OCA\Dossiq\Repair\ProvisionAssignedGroups; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * The shipped flows' assigned groups are provisioned, idempotently. + * + * On a fresh install the shipped case flow assigns `task-behandelaar` to the + * group `behandelaars`, and the completion gate fails closed on a group that + * does not exist — proven to red 7 of 9 journey specs until `occ group:add + * behandelaars` was run by hand. These tests pin the repair step that now + * does that at install, and sweep the shipped register data so a NEW group + * assignment cannot ship without its provisioning. + * + * @covers \OCA\Dossiq\Repair\ProvisionAssignedGroups + */ +class ProvisionAssignedGroupsTest extends TestCase { + + /** + * A missing group is created and reported. + * + * @return void + */ + public function testCreatesEveryMissingAssignedGroup(): void { + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('groupExists')->willReturn(false); + $groupManager->expects($this->exactly(count(ProvisionAssignedGroups::ASSIGNED_GROUPS))) + ->method('createGroup') + ->willReturn($this->createMock(IGroup::class)); + + $output = $this->createMock(IOutput::class); + $output->expects($this->atLeastOnce())->method('info'); + $output->expects($this->never())->method('warning'); + + $step = new ProvisionAssignedGroups(groupManager: $groupManager, logger: new NullLogger()); + $step->run($output); + } + + /** + * An existing group is left alone — the step is idempotent. + * + * @return void + */ + public function testLeavesAnExistingGroupAlone(): void { + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('groupExists')->willReturn(true); + $groupManager->expects($this->never())->method('createGroup'); + + $step = new ProvisionAssignedGroups(groupManager: $groupManager, logger: new NullLogger()); + $step->run($this->createMock(IOutput::class)); + } + + /** + * A backend that refuses group creation is reported loudly, not swallowed. + * + * @return void + */ + public function testWarnsWhenTheBackendRefusesCreation(): void { + $groupManager = $this->createMock(IGroupManager::class); + $groupManager->method('groupExists')->willReturn(false); + $groupManager->method('createGroup')->willReturn(null); + + $output = $this->createMock(IOutput::class); + $output->expects($this->atLeastOnce())->method('warning'); + + $step = new ProvisionAssignedGroups(groupManager: $groupManager, logger: new NullLogger()); + $step->run($output); + } + + /** + * Every literal assignee in the shipped flows is a provisioned group. + * + * Sweeps every `dossiq.askPerson` node in the shipped register files: an + * assignee that is not a template expression must appear in + * ProvisionAssignedGroups::ASSIGNED_GROUPS, so assigning shipped work to + * an unprovisioned principal fails here instead of on a fresh install. + * + * @return void + */ + public function testEveryShippedLiteralAssigneeIsProvisioned(): void { + $files = [ + __DIR__ . '/../../../lib/Settings/dossiq_register.json', + ]; + foreach ((array)glob(__DIR__ . '/../../../lib/Settings/register.d/*.json') as $file) { + $files[] = (string)$file; + } + + $assignees = []; + foreach ($files as $file) { + $data = json_decode((string)file_get_contents($file), true); + if (is_array($data) === false) { + continue; + } + + $this->collectAskPersonAssignees(node: $data, file: basename($file), found: $assignees); + } + + $this->assertNotEmpty($assignees, 'The sweep found no shipped askPerson assignees at all — the query is broken, not the data clean'); + + foreach ($assignees as $entry) { + $this->assertContains( + $entry['assignee'], + ProvisionAssignedGroups::ASSIGNED_GROUPS, + sprintf( + '%s assigns a shipped askPerson step to "%s", which ProvisionAssignedGroups does not provision — the completion gate will refuse every actor on a fresh install', + $entry['file'], + $entry['assignee'] + ) + ); + } + } + + /** + * Collect the literal assignees of every dossiq.askPerson node. + * + * @param mixed $node The JSON node. + * @param string $file The file being swept (for messages). + * @param array $found Accumulator. + * + * @return void + */ + private function collectAskPersonAssignees(mixed $node, string $file, array &$found): void { + if (is_array($node) === false) { + return; + } + + $type = ($node['type'] ?? ''); + if ($type === 'dossiq.askPerson') { + $config = (array)($node['config'] ?? []); + $assignee = trim((string)($config['assignee'] ?? '')); + if ($assignee !== '' && str_contains($assignee, '{{') === false) { + $found[] = ['file' => $file, 'assignee' => $assignee]; + } + } + + foreach ($node as $value) { + $this->collectAskPersonAssignees(node: $value, file: $file, found: $found); + } + } +}//end class diff --git a/tests/Unit/Repair/SeedDeadlineMonitoringDataTest.php b/tests/Unit/Repair/SeedDeadlineMonitoringDataTest.php new file mode 100644 index 000000000..61dcc549e --- /dev/null +++ b/tests/Unit/Repair/SeedDeadlineMonitoringDataTest.php @@ -0,0 +1,132 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\Dossiq\Tests\Unit\Repair + * @author Conduction B.V. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @link https://github.com/ConductionNL/dossiq + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Repair; + +use OCA\Dossiq\Repair\SeedDeadlineMonitoringData; +use OCA\Dossiq\Service\DeadlineMonitoringSeedDataService; +use OCA\Dossiq\Service\SettingsService; +use OCP\Migration\IOutput; +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +/** + * The termijnbewaking seed runs under a system identity and fails loudly. + * + * A repair step executes during `occ upgrade` with no session, so OpenRegister + * refuses every write as Anonymous. Before this step elevated, all three rows + * failed and the summary still read "0 definities (0 overgeslagen)": no + * TermijnDefinitie existed on any fresh install, so no termijn timer ever + * armed. These tests pin both halves of the fix. + * + * @covers \OCA\Dossiq\Repair\SeedDeadlineMonitoringData + * @uses \OCA\Dossiq\Repair\Support\RunsUnderSystemIdentity + * @uses \OCA\Dossiq\Service\DeadlineMonitoringSeedDataService + */ +class SeedDeadlineMonitoringDataTest extends TestCase { + + /** + * Build the repair step around a seed result and a recording ObjectService. + * + * @param array $seedResult What the seed service reports. + * @param object|null $objectService What SettingsService hands out. + * + * @return SeedDeadlineMonitoringData The step under test. + */ + private function step(array $seedResult, ?object $objectService): SeedDeadlineMonitoringData { + $seedService = $this->createMock(DeadlineMonitoringSeedDataService::class); + $seedService->method('seed')->willReturn($seedResult); + + $settings = $this->createMock(SettingsService::class); + $settings->method('isOpenRegisterAvailable')->willReturn(true); + $settings->method('getObjectService')->willReturn($objectService); + + return new SeedDeadlineMonitoringData( + seedService: $seedService, + settingsService: $settings, + logger: new NullLogger(), + ); + } + + /** + * The seed is elevated through runAsSystem when OpenRegister offers it. + * + * @return void + */ + public function testSeedRunsUnderTheSystemIdentity(): void { + $objectService = new RecordingSystemIdentityObjectService(); + + $output = $this->createMock(IOutput::class); + $output->expects($this->atLeastOnce())->method('info'); + $output->expects($this->never())->method('warning'); + + $this->step( + seedResult: ['success' => true, 'definities' => 3, 'skipped' => 0, 'failed' => 0], + objectService: $objectService, + )->run($output); + + $this->assertSame(1, $objectService->elevations, 'The seed must run inside runAsSystem()'); + } + + /** + * Refused rows turn the summary into a warning, never success-shaped info. + * + * @return void + */ + public function testRefusedRowsProduceAWarningNotSuccessShapedOutput(): void { + $warnings = []; + $output = $this->createMock(IOutput::class); + $output->method('warning')->willReturnCallback( + static function (string $message) use (&$warnings): void { + $warnings[] = $message; + } + ); + + $this->step( + seedResult: ['success' => true, 'definities' => 0, 'skipped' => 0, 'failed' => 3], + objectService: new RecordingSystemIdentityObjectService(), + )->run($output); + + $this->assertNotEmpty($warnings, 'A seed that seeded nothing must warn'); + $this->assertStringContainsString('3 rijen geweigerd', $warnings[0]); + } +}//end class + +/** + * ObjectService stand-in that counts runAsSystem() elevations. + */ +class RecordingSystemIdentityObjectService { + + /** + * How many times runAsSystem() was entered. + * + * @var integer + */ + public int $elevations = 0; + + /** + * Mirror of ObjectService::runAsSystem(). + * + * @param callable $work The elevated work. + * + * @return mixed The work's return value. + */ + public function runAsSystem(callable $work): mixed { + $this->elevations++; + + return $work(); + } +} diff --git a/tests/Unit/Repair/SeedVerwerkingsactiviteitenTest.php b/tests/Unit/Repair/SeedVerwerkingsactiviteitenTest.php index 1cdb983c3..dbe97ef5b 100644 --- a/tests/Unit/Repair/SeedVerwerkingsactiviteitenTest.php +++ b/tests/Unit/Repair/SeedVerwerkingsactiviteitenTest.php @@ -133,9 +133,9 @@ public function testFreshRunInsertsCatalogueAsDrafts(): void { $default = $this->mapper->findByCode(code: 'zaakafhandeling'); $this->assertNotNull($default, 'the default attribution activity must be seeded'); $this->assertSame('draft', $default->getStatus(), 'seeded activities are drafts for FG review'); - $this->assertNotEmpty($default->getNaam()); - $this->assertNotEmpty($default->getDoelbinding()); - $this->assertContains($default->getRechtsgrond(), Verwerkingsactiviteit::RECHTSGROND_VOCABULARY); + $this->assertNotEmpty($default->getName()); + $this->assertNotEmpty($default->getPurpose()); + $this->assertContains($default->getLegalBasis(), Verwerkingsactiviteit::RECHTSGROND_VOCABULARY); }//end testFreshRunInsertsCatalogueAsDrafts() @@ -153,14 +153,14 @@ public function testRerunPreservesFgActivatedStatus(): void { $this->step->run($this->createMock(IOutput::class)); $published = $this->mapper->findByCode(code: 'zaakafhandeling'); $published->setStatus('published'); - $published->setNaam('FG-renamed'); + $published->setName('FG-renamed'); // Second run (app upgrade) refreshes fields, preserves status. $this->step->run($this->createMock(IOutput::class)); $after = $this->mapper->findByCode(code: 'zaakafhandeling'); $this->assertSame('published', $after->getStatus(), 'FG activation must survive dossiq upgrades'); - $this->assertNotSame('FG-renamed', $after->getNaam(), 'descriptive fields refresh from the catalogue'); + $this->assertNotSame('FG-renamed', $after->getName(), 'descriptive fields refresh from the catalogue'); $this->assertGreaterThan(0, $this->mapper->updates); }//end testRerunPreservesFgActivatedStatus() diff --git a/tests/Unit/Service/DeadlineMonitoringSeedDataServiceTest.php b/tests/Unit/Service/DeadlineMonitoringSeedDataServiceTest.php index 6d6e484bc..efa034a24 100644 --- a/tests/Unit/Service/DeadlineMonitoringSeedDataServiceTest.php +++ b/tests/Unit/Service/DeadlineMonitoringSeedDataServiceTest.php @@ -98,6 +98,39 @@ public function testWooSeedHasCustomRegime(): void { self::assertSame(50000, $woo['deviatingPenaltyPaymentRegime']['plafond']); } + /** + * A refused row is COUNTED, so the caller can refuse a success-shaped + * report. This is the "0 definities (0 overgeslagen)" defect: every row + * failed under Anonymous RBAC and the summary still looked like success. + * + * @return void + */ + public function testRefusedRowsAreCountedAsFailed(): void { + $objects = new RefusingTermijnObjectService(); + $settings = $this->createMock(SettingsService::class); + $settings->method('getObjectService')->willReturn($objects); + $settings->method('getConfigValue')->willReturnCallback( + static function (string $key): string { + return match ($key) { + 'register' => 'dossiq', + 'termijn_definitie_schema' => 'deadlineDefinition', + default => '', + }; + }, + ); + + $service = new DeadlineMonitoringSeedDataService( + $settings, + $this->createMock(LoggerInterface::class), + ); + + $result = $service->seed(); + + self::assertSame(0, $result['definities']); + self::assertSame(0, $result['skipped']); + self::assertSame(3, $result['failed']); + } + /** * @return void */ @@ -180,3 +213,26 @@ public function searchObjects(array $query = []): array { return $this->findObjects('', $schema); } } + +/** + * Fake that refuses every write, the way OpenRegister RBAC refuses Anonymous. + */ +class RefusingTermijnObjectService extends FakeTermijnObjectService { + /** + * @param array $object Object. + * @param array|null $extend Relations to expand (ignored). + * @param string|int|null $register Register id. + * @param string|int|null $schema Schema id. + * @param string|null $uuid UUID to update, null to create. + * @return array + */ + public function saveObject( + array $object, + ?array $extend = [], + string|int|null $register = null, + string|int|null $schema = null, + ?string $uuid = null, + ): array { + throw new \RuntimeException("User 'Anonymous' does not have permission to 'create'"); + } +} diff --git a/tests/Unit/Service/Parafeer/ParaferingDelegationServiceTest.php b/tests/Unit/Service/Parafeer/ParaferingDelegationServiceTest.php index 3e321b8d2..7346235d6 100644 --- a/tests/Unit/Service/Parafeer/ParaferingDelegationServiceTest.php +++ b/tests/Unit/Service/Parafeer/ParaferingDelegationServiceTest.php @@ -177,6 +177,131 @@ public function testUnknownStepTypeBecomesEndorsement(): void { }//end testUnknownStepTypeBecomesEndorsement() + /** + * Local actor types are translated into the decision app's vocabulary. + * + * The decision app's step schema accepts ONLY person|body|role, and its + * store refuses the whole route on anything else. `group` was copied + * verbatim, so all three shipped demo routes with a group actor came back + * "not handled" on every fresh install. A group resolves the way a role + * does (by the consuming context, at completion time), so it travels as + * `role`; a local `user` names a person directly. + * + * @return void + */ + public function testActorTypesAreTranslatedIntoTheDecisionAppVocabulary(): void { + $route = $this->route(); + $route['steps'] = [ + ['order' => 1, 'type' => 'advice', 'actorType' => 'group', 'actor' => 'juridische-dienst'], + ['order' => 2, 'type' => 'parafering', 'actorType' => 'user', 'actor' => 'j.bakker'], + ['order' => 3, 'type' => 'accordering', 'actorType' => 'role', 'actor' => 'portefeuillehouder'], + ['order' => 4, 'type' => 'accordering', 'actorType' => 'body', 'actor' => 'college'], + ]; + + $this->service()->holdRoute(route: $route); + + $this->assertSame( + ['role', 'person', 'role', 'body'], + array_column($this->command()->getSteps(), 'actorType') + ); + + }//end testActorTypesAreTranslatedIntoTheDecisionAppVocabulary() + + /** + * The schema's own `advice` spelling maps to advisory, not the fallback. + * + * The parafeerroute schema enum says `advice`; the mapping table only knew + * the Dutch `advies`, so every shipped advice step travelled as a generic + * endorsement. + * + * @return void + */ + public function testAdviceStepTypeMapsToAdvisory(): void { + $route = $this->route(); + $route['steps'] = [['order' => 1, 'type' => 'advice', 'actor' => 'planologisch-adviseur']]; + + $this->service()->holdRoute(route: $route); + + $this->assertSame('advisory', $this->command()->getSteps()[0]['stageType']); + + }//end testAdviceStepTypeMapsToAdvisory() + + /** + * Every SHIPPED parafeerroute maps into what the decision app accepts. + * + * Drives the real service over the demo routes dossiq ships (register + * seed and bvw bundles alike) and asserts each mapped step lands inside + * the decision app's frozen step vocabulary. The enums are the decision + * app's ApprovalRoute step schema (decidiq lib/Settings/register.d/ + * 69-approval-routes.json); a value outside them makes the store refuse + * the route, which the producer sees only as "not handled". + * + * @return void + */ + public function testEveryShippedParafeerrouteMapsIntoTheAcceptedVocabulary(): void { + $acceptedStageTypes = ['preparatory', 'advisory', 'endorsement', 'decisive', 'ratifying']; + $acceptedActorTypes = ['person', 'body', 'role']; + + foreach ($this->shippedParafeerroutes() as $label => $route) { + $this->dispatched = []; + $route['id'] = ($route['id'] ?? ('shipped-' . md5($label))); + + $this->service()->holdRoute(route: $route); + + $steps = $this->command()->getSteps(); + $this->assertNotEmpty($steps, $label . ' mapped to zero steps'); + foreach ($steps as $index => $step) { + $this->assertContains( + (string)($step['stageType'] ?? ''), + $acceptedStageTypes, + sprintf('%s step[%d] maps to a stageType the decision app refuses', $label, (int)$index) + ); + if (isset($step['actorType']) === true) { + $this->assertContains( + (string)$step['actorType'], + $acceptedActorTypes, + sprintf('%s step[%d] maps to an actorType the decision app refuses', $label, (int)$index) + ); + } + } + }//end foreach + + }//end testEveryShippedParafeerrouteMapsIntoTheAcceptedVocabulary() + + /** + * Every parafeerroute dossiq ships, keyed by a human-readable label. + * + * @return array> The shipped routes. + */ + private function shippedParafeerroutes(): array { + $routes = []; + + $register = json_decode( + (string)file_get_contents(__DIR__ . '/../../../../lib/Settings/dossiq_register.json'), + true + ); + foreach ((array)(((array)($register['components'] ?? []))['objects'] ?? []) as $object) { + $self = (array)(((array)$object)['@self'] ?? []); + if ((string)($self['schema'] ?? '') === 'parafeerroute') { + $routes['dossiq_register.json ' . (string)($self['slug'] ?? '?')] = (array)$object; + } + } + + foreach ((array)glob(__DIR__ . '/../../../../lib/Settings/templates/bvw-*.json') as $file) { + $bundle = json_decode((string)file_get_contents((string)$file), true); + $route = (array)(((array)(((array)$bundle)['caseType'] ?? []))['parafeerroute'] + ?? (((array)$bundle)['parafeerroute'] ?? [])); + if ($route !== []) { + $routes[basename((string)$file)] = $route; + } + } + + $this->assertNotEmpty($routes, 'The sweep found no shipped parafeerroutes at all — the query is broken, not the data clean'); + + return $routes; + + }//end shippedParafeerroutes() + /** * A step with no order gets its position, so the sequence is never lost. * diff --git a/tests/Unit/Settings/BvwTemplateConformanceTest.php b/tests/Unit/Settings/BvwTemplateConformanceTest.php new file mode 100644 index 000000000..2afd54dc6 --- /dev/null +++ b/tests/Unit/Settings/BvwTemplateConformanceTest.php @@ -0,0 +1,271 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\Dossiq\Tests\Unit\Settings + * @author Conduction B.V. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @link https://github.com/ConductionNL/dossiq + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Settings; + +use OCA\Dossiq\Service\Transitions\TransitionSpecReader; +use PHPUnit\Framework\TestCase; + +/** + * Conformance of the shipped besluitvorming (bvw) template bundles. + * + * These bundles are seeded verbatim on every fresh install, so a defect in + * them is a defect on every fresh install. The "One engine" acceptance proof + * found three, and each test here encodes one so it cannot ship again: + * + * 1. ACTION PLACEMENT. The transition engine dispatches ONLY the actions the + * real {@see TransitionSpecReader} extracts from a TRANSITION. The bundles + * declared `besluitvormingActivate` / `besluitvormingPublish` on STEPS, + * a position nothing reads: "Start parafering" returned 200 with + * `dispatchedActions: []` and no parafering was ever raised. The test + * drives the real reader over the shipped JSON, so it asserts what the + * engine actually sees, not what the file looks like. + * + * 2. RESULT-TYPE VOCABULARY. The register's resultType schema constrains + * `archivalAction` to a Dutch enum; the bundles wrote English + * `keep`/`destroy`, failing 9 objects at enable on every install. + * + * 3. INITIAL STATUS. A caseType without an initial status bears cases that + * are born statusless when created through the API. Each bundle must name + * its initial status, and the name must resolve to a declared statusType. + * + * @covers \OCA\Dossiq\Service\Transitions\TransitionSpecReader + */ +class BvwTemplateConformanceTest extends TestCase { + + /** + * The shipped bvw template bundle files. + * + * @return array> Decoded bundles keyed by filename. + */ + private function shippedBundles(): array { + $dir = __DIR__ . '/../../../lib/Settings/templates'; + $bundles = []; + foreach ((array)glob($dir . '/bvw-*.json') as $file) { + $decoded = json_decode((string)file_get_contents((string)$file), true); + $this->assertIsArray($decoded, basename((string)$file) . ' must be valid JSON'); + $bundles[basename((string)$file)] = $decoded; + } + + $this->assertNotEmpty($bundles, 'No shipped bvw template bundles found'); + + return $bundles; + } + + /** + * Collect every action type declared anywhere under a node. + * + * @param mixed $node The JSON node. + * @param array $types Accumulator of action type names. + * + * @return void + */ + private function collectDeclaredActionTypes(mixed $node, array &$types): void { + if (is_array($node) === false) { + return; + } + + foreach ($node as $key => $value) { + if (($key === 'automaticActions' || $key === 'actions') && is_array($value) === true) { + foreach ($value as $action) { + if (is_array($action) === true && isset($action['type']) === true) { + $types[] = (string)$action['type']; + } + } + } + + $this->collectDeclaredActionTypes(node: $value, types: $types); + } + } + + /** + * Every declared automaticAction sits where the engine actually reads it. + * + * The engine's sole action source is TransitionSpecReader::extractActions() + * over a TRANSITION (StatusTransitionService::execute()). So: no step may + * carry automaticActions, and every action type the bundle declares must be + * extracted by the real reader from at least one transition. + * + * @return void + */ + public function testEveryDeclaredAutomaticActionSitsWhereTheEngineReads(): void { + $reader = new TransitionSpecReader(); + + foreach ($this->shippedBundles() as $file => $bundle) { + $workflow = (array)($bundle['caseType']['workflowTemplate'] ?? []); + $this->assertNotEmpty($workflow, $file . ' must ship a workflowTemplate'); + + // No step may carry actions: the engine never reads them there. + foreach ((array)($workflow['steps'] ?? []) as $index => $step) { + foreach (['automaticActions', 'actions'] as $key) { + $this->assertArrayNotHasKey( + $key, + (array)$step, + sprintf( + '%s step[%d] ("%s") declares %s, a position the transition engine never reads — move them to the transition that enters this status', + $file, + (int)$index, + (string)($step['statusName'] ?? ''), + $key + ) + ); + } + } + + // Everything declared must be dispatchable: extracted by the real + // reader from some transition. + $declared = []; + $this->collectDeclaredActionTypes(node: $workflow, types: $declared); + $this->assertNotEmpty($declared, $file . ' is expected to declare automatic actions'); + + $extracted = []; + foreach ((array)($workflow['transitions'] ?? []) as $transition) { + foreach ($reader->extractActions(transition: (array)$transition) as $action) { + $extracted[] = (string)($action['type'] ?? ''); + } + } + + foreach (array_unique($declared) as $type) { + $this->assertContains( + $type, + $extracted, + sprintf( + '%s declares action "%s" at a position TransitionSpecReader never extracts it from — the engine would silently drop it', + $file, + $type + ) + ); + } + }//end foreach + } + + /** + * The parafering seam arms when parafering STARTS, not when it ends. + * + * besluitvormingActivate must be extracted from the transition INTO the + * Parafering status, and besluitvormingPublish (where declared) from the + * transition INTO Bekendmaking. + * + * @return void + */ + public function testSeamActionsFireOnTheTransitionEnteringTheirStatus(): void { + $reader = new TransitionSpecReader(); + + $expectations = [ + 'besluitvormingActivate' => 'Parafering', + 'besluitvormingPublish' => 'Bekendmaking', + ]; + + foreach ($this->shippedBundles() as $file => $bundle) { + $workflow = (array)($bundle['caseType']['workflowTemplate'] ?? []); + $declared = []; + $this->collectDeclaredActionTypes(node: $workflow, types: $declared); + + foreach ($expectations as $type => $toStatusName) { + if (in_array($type, $declared, true) === false) { + continue; + } + + $carriers = []; + foreach ((array)($workflow['transitions'] ?? []) as $transition) { + foreach ($reader->extractActions(transition: (array)$transition) as $action) { + if ((string)($action['type'] ?? '') === $type) { + $carriers[] = (string)($transition['toStatusName'] ?? ''); + } + } + } + + $this->assertSame( + [$toStatusName], + $carriers, + sprintf( + '%s must dispatch %s exactly once, on the transition entering "%s"', + $file, + $type, + $toStatusName + ) + ); + } + }//end foreach + } + + /** + * Shipped resultTypes speak the register's archivalAction vocabulary. + * + * The enum is read from the shipped register file rather than restated, so + * a vocabulary change there re-checks the bundles automatically. + * + * @return void + */ + public function testResultTypeArchivalActionsMatchTheRegisterEnum(): void { + $register = json_decode( + (string)file_get_contents(__DIR__ . '/../../../lib/Settings/dossiq_register.json'), + true + ); + $enum = (array)($register['components']['schemas']['resultType']['properties']['archivalAction']['enum'] ?? []); + $this->assertNotEmpty($enum, 'The resultType schema must constrain archivalAction'); + + foreach ($this->shippedBundles() as $file => $bundle) { + foreach ((array)($bundle['caseType']['resultTypes'] ?? []) as $index => $resultType) { + $action = (string)(((array)$resultType)['archivalAction'] ?? ''); + if ($action === '') { + continue; + } + + $this->assertContains( + $action, + $enum, + sprintf( + '%s resultTypes[%d] ("%s") writes archivalAction "%s", which the register enum (%s) refuses at enable', + $file, + (int)$index, + (string)(((array)$resultType)['name'] ?? ''), + $action, + implode(', ', $enum) + ) + ); + } + } + } + + /** + * Every bundle names its initial status, and the name resolves. + * + * @return void + */ + public function testEveryCaseTypeNamesAResolvableInitialStatus(): void { + foreach ($this->shippedBundles() as $file => $bundle) { + $caseType = (array)($bundle['caseType'] ?? []); + $initial = (string)($caseType['initialStatusName'] ?? ''); + $this->assertNotSame( + '', + $initial, + $file . ' caseType declares no initialStatusName: an API-created case would be born statusless' + ); + + $statusNames = []; + foreach ((array)($caseType['statusTypes'] ?? []) as $statusType) { + $statusNames[] = (string)(((array)$statusType)['name'] ?? ''); + } + + $this->assertContains( + $initial, + $statusNames, + $file . ' initialStatusName must name a declared statusType' + ); + } + } +}//end class diff --git a/tests/Unit/Settings/RegisterSchemaUnionTypeTest.php b/tests/Unit/Settings/RegisterSchemaUnionTypeTest.php new file mode 100644 index 000000000..83333604f --- /dev/null +++ b/tests/Unit/Settings/RegisterSchemaUnionTypeTest.php @@ -0,0 +1,105 @@ + + * SPDX-License-Identifier: EUPL-1.2 + * + * @category Test + * @package OCA\Dossiq\Tests\Unit\Settings + * @author Conduction B.V. + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * @link https://github.com/ConductionNL/dossiq + */ + +declare(strict_types=1); + +namespace OCA\Dossiq\Tests\Unit\Settings; + +use PHPUnit\Framework\TestCase; + +/** + * No shipped schema property declares a union `type` array. + * + * OpenRegister's importer refuses a property whose `type` is an array (for + * example `["number", "null"]`) and DROPS THE WHOLE SCHEMA, silently: every + * other property of that schema vanishes with it, and nothing in the import + * summary says so. The acceptance proof caught `mandateArrangement` missing + * on every fresh install because of exactly one such property + * (`mandateGroups.items.properties.to_amount`). Nullability is expressed by + * omitting the property, never by a union type. + */ +class RegisterSchemaUnionTypeTest extends TestCase { + + /** + * Every shipped register file whose schemas are imported. + * + * @return array Absolute file paths. + */ + private function shippedRegisterFiles(): array { + $files = [__DIR__ . '/../../../lib/Settings/dossiq_register.json']; + foreach ((array)glob(__DIR__ . '/../../../lib/Settings/register.d/*.json') as $file) { + $files[] = (string)$file; + } + + return $files; + } + + /** + * Sweep every schema property for a union `type`. + * + * @return void + */ + public function testNoShippedSchemaPropertyDeclaresAUnionType(): void { + $offenders = []; + $schemasSeen = 0; + + foreach ($this->shippedRegisterFiles() as $file) { + $data = json_decode((string)file_get_contents($file), true); + if (is_array($data) === false) { + continue; + } + + $schemas = (array)(((array)($data['components'] ?? []))['schemas'] ?? []); + foreach ($schemas as $name => $schema) { + $schemasSeen++; + $this->collectUnionTypes( + node: $schema, + path: basename($file) . ' ' . (string)$name, + offenders: $offenders, + ); + } + } + + $this->assertGreaterThan(0, $schemasSeen, 'The sweep saw no schemas at all — the query is broken, not the data clean'); + $this->assertSame( + [], + $offenders, + "Union `type` arrays make OpenRegister drop the WHOLE schema on import, silently:\n" . implode("\n", $offenders) + ); + } + + /** + * Collect every `type` given as an array. + * + * @param mixed $node The JSON node. + * @param string $path The path walked so far. + * @param array $offenders Accumulator of findings. + * + * @return void + */ + private function collectUnionTypes(mixed $node, string $path, array &$offenders): void { + if (is_array($node) === false) { + return; + } + + $type = ($node['type'] ?? null); + if (is_array($type) === true && array_is_list($type) === true) { + $offenders[] = $path . ' declares type [' . implode(', ', array_map('strval', $type)) . ']'; + } + + foreach ($node as $key => $value) { + $this->collectUnionTypes(node: $value, path: $path . '/' . (string)$key, offenders: $offenders); + } + } +}//end class