From bb375bfc9a26ba3ca3634806756483f5e176933d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 11:37:50 +0200 Subject: [PATCH 1/2] fix(install): the parafering seam and termijn timers never armed on a fresh install The "One engine" acceptance proof found seven fresh-install defects, each reproduced twice on a clean rig. They share a shape: the install reported success while the thing it installed did nothing. A. The shipped bvw templates declared besluitvormingActivate and besluitvormingPublish on the STEP. The engine's only action source is TransitionSpecReader::extractActions() over a TRANSITION, so "Start parafering" returned 200 with dispatchedActions: [] and no parafering was ever raised. The templates move, not the engine: the workflow-definition model spec gives steps and transitions each their own automaticActions, but the engine implements only the transition half, and widening it would have made "actions on step completion" mean "actions on status entry" for every app. besluitvormingActivate now sits on the transition INTO Parafering, besluitvormingPublish on the transition INTO Bekendmaking, in all three bundles. A template that still declares actions at an unread position now logs a warning at activation, so the no-op is loud. B. The shipped case flow assigns task-behandelaar to the group behandelaars, which nothing provisioned. The completion signal was refused fail-closed for every actor and the journey suite red 7/9. A new idempotent repair step creates the group at install; membership stays the administrator's. The flow is NOT reassigned to admin: that would hide a provisioning gap behind an over-privileged default. C. SeedDeadlineMonitoringData wrote as Anonymous, RBAC refused every row, and the failures were swallowed into "0 definities (0 overgeslagen)". It now runs under the system identity its sibling steps already use, and refused rows are COUNTED so a seed that seeded nothing cannot report success-shaped output. D. The bvw resultTypes wrote English archivalAction values into the register's Dutch enum, failing 9 objects at enable on every install. E. mandateArrangement declared a union type on to_amount, which makes OpenRegister drop the WHOLE schema on import, silently. F. The demo parafeerroutes were never held. The decision app's step schema accepts only person|body|role and refuses the route on anything else, so every route with a group actor came back "not handled". actorType is now translated rather than copied, and the schema's own `advice` spelling maps to advisory instead of falling back to a generic endorsement. G. The bvw caseTypes named no initial status, so an API-created case was born statusless; the verwerkingsactiviteit seeder called setters for columns OpenRegister has since renamed, failing all 7 rows with "naam is not a valid attribute"; and the ZGW-mapping repair threw on an empty schema context, which aborts an install. Tests. BvwTemplateConformanceTest drives the REAL TransitionSpecReader over the shipped JSON and asserts every declared action sits where the engine reads it, plus the enum and initial-status invariants. Proven red on all four axes before the fix. RegisterSchemaUnionTypeTest sweeps every shipped schema for union types; ProvisionAssignedGroupsTest sweeps the shipped flows so a new group assignment cannot ship without its provisioning; and ParaferingDelegationServiceTest now maps every SHIPPED route and asserts it lands inside the decision app's frozen vocabulary. The Verwerkingsactiviteit stub was a setter sink that accepted any attribute, so it could not fail; it now mirrors the real entity's declared properties and throws the way a QBMapper entity does. Co-Authored-By: Claude Opus 5 (1M context) --- appinfo/info.xml | 7 + docs/admin/groups.md | 30 ++ lib/Repair/LoadDefaultZgwMappings.php | 38 ++- lib/Repair/ProvisionAssignedGroups.php | 121 ++++++++ lib/Repair/SeedDeadlineMonitoringData.php | 33 ++- lib/Repair/SeedVerwerkingsactiviteiten.php | 61 +++- .../Besluitvorming/TemplateBundleSeeder.php | 139 +++++++-- lib/Service/BesluitvormingTemplateService.php | 45 +++ .../DeadlineMonitoringSeedDataService.php | 9 +- .../Parafeer/ParaferingDelegationService.php | 41 ++- lib/Settings/register.d/30-beschikking.json | 8 +- .../templates/bvw-college-besluit.json | 19 +- .../templates/bvw-mandaatbesluit.json | 14 +- lib/Settings/templates/bvw-raadsbesluit.json | 19 +- tests/Stubs/Db/Verwerkingsactiviteit.php | 84 ++++-- .../Repair/ProvisionAssignedGroupsTest.php | 161 +++++++++++ .../Repair/SeedDeadlineMonitoringDataTest.php | 132 +++++++++ .../SeedVerwerkingsactiviteitenTest.php | 10 +- .../DeadlineMonitoringSeedDataServiceTest.php | 56 ++++ .../ParaferingDelegationServiceTest.php | 125 ++++++++ .../Settings/BvwTemplateConformanceTest.php | 271 ++++++++++++++++++ .../Settings/RegisterSchemaUnionTypeTest.php | 105 +++++++ 22 files changed, 1430 insertions(+), 98 deletions(-) create mode 100644 docs/admin/groups.md create mode 100644 lib/Repair/ProvisionAssignedGroups.php create mode 100644 tests/Unit/Repair/ProvisionAssignedGroupsTest.php create mode 100644 tests/Unit/Repair/SeedDeadlineMonitoringDataTest.php create mode 100644 tests/Unit/Settings/BvwTemplateConformanceTest.php create mode 100644 tests/Unit/Settings/RegisterSchemaUnionTypeTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index c01f47ed87..5a6a9a69b5 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 0000000000..fa0262318b --- /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 3cf1363ab9..a2c494b661 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 0000000000..878d5cf079 --- /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 d82d195111..52e350f440 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 c93fdef470..311e64da78 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 f563929712..6604843a4a 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 ba97ecd8b9..fc8ce0a1bc 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 29f739f94c..272226defa 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 9a384ed68c..d2745f6cc3 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/register.d/30-beschikking.json b/lib/Settings/register.d/30-beschikking.json index cfabbf32f2..117f08d63e 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 84bc849eff..2964872c09 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 427c1097b4..2c69951227 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 70c267f4d8..401e95559d 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 41965172ea..a2f121e903 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 0000000000..da9e1e3c92 --- /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 0000000000..61dcc549e7 --- /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 1cdb983c39..dbe97ef5b9 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 6d6e484bc0..efa034a24a 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 3e321b8d2e..7346235d65 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 0000000000..2afd54dc6a --- /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 0000000000..83333604f3 --- /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 From fffbe7d437f3f9088328a4dd8afdcd01019a2fd7 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 3 Sep 2026 11:52:48 +0200 Subject: [PATCH 2/2] fix(schemas): the mock register still documented a null that the schema refuses Fixing mandateArrangement's union type left the mock register's copy of to_amount saying "null means unlimited". The mock already typed the property as a plain number, so that sentence described a value neither schema accepts. It also grew the untranslated-schema-string count by one and reddened the check:schema-l10n ratchet: the extractor dedupes by string VALUE across files, so the old wording survived here and the corrected wording landed as a genuinely new string. Syncing the two retires the old string instead of adding a second, which is why the count returns to its baseline rather than needing the baseline raised. Co-Authored-By: Claude Opus 5 (1M context) --- lib/Settings/dossiq_mock_register.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Settings/dossiq_mock_register.json b/lib/Settings/dossiq_mock_register.json index b26c02f417..719c1e229b 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",