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