diff --git a/appinfo/info.xml b/appinfo/info.xml
index b895c7352..8ac28b807 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -314,6 +314,15 @@ Vrij en open source onder de EUPL-1.2-licentie.
install has no history to move.
-->
OCA\Dossiq\Repair\MigrateAiOversightToHermiq
+
+ OCA\Dossiq\Repair\BackfillAdviceRequestObjection
-
-
-
⋮⋮
-
{{ item.agendanummer || '–' }}
-
{{
- item.title || t('dossiq', 'Onbenoemd voorstel')
- }}
-
-
- {{ t('dossiq', 'Hamerstuk') }}
-
-
- {{ t('dossiq', 'Bespreekstuk') }}
-
-
-
-
-
-
-
-
diff --git a/src/dialogs/AddStepDialog.vue b/src/dialogs/AddStepDialog.vue
deleted file mode 100644
index 7b1be53c7..000000000
--- a/src/dialogs/AddStepDialog.vue
+++ /dev/null
@@ -1,221 +0,0 @@
-
-
-
-
-
-
-
- {{ t('dossiq', 'Invoegen na stap') }}
-
-
-
-
-
- {{ t('dossiq', 'Stap type') }}
-
-
-
-
-
- {{ t('dossiq', 'Actor type') }}
-
-
-
-
- (actor = v)" />
-
-
- (mandatory = v)">
- {{ t('dossiq', 'Verplichte stap') }}
-
-
-
-
- {{ error }}
-
-
-
-
- {{ t('dossiq', 'Annuleren') }}
-
-
- {{
- submitting
- ? t('dossiq', 'Bezig...')
- : t('dossiq', 'Stap toevoegen')
- }}
-
-
-
-
-
-
-
-
diff --git a/src/dialogs/SkipStepDialog.vue b/src/dialogs/SkipStepDialog.vue
index d175e957b..24c22bbb6 100644
--- a/src/dialogs/SkipStepDialog.vue
+++ b/src/dialogs/SkipStepDialog.vue
@@ -55,7 +55,7 @@
-
-
diff --git a/src/views/besluitvorming/VergaderingDetailView.vue b/src/views/besluitvorming/VergaderingDetailView.vue
deleted file mode 100644
index e166d097a..000000000
--- a/src/views/besluitvorming/VergaderingDetailView.vue
+++ /dev/null
@@ -1,247 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/src/views/voorstellen/VoorstelDetail.vue b/src/views/voorstellen/VoorstelDetail.vue
index b5f29de4e..784e510c7 100644
--- a/src/views/voorstellen/VoorstelDetail.vue
+++ b/src/views/voorstellen/VoorstelDetail.vue
@@ -61,12 +61,15 @@
v-if="canOverrideRoute"
:title="t('dossiq', 'Route-aanpassing (manager)')">
+
{{ t('dossiq', 'Stap overslaan') }}
-
- {{ t('dossiq', 'Stap toevoegen') }}
-
@@ -77,13 +80,6 @@
@skipped="onOverrideCompleted"
@close="showSkipDialog = false" />
-
-
-
+
diff --git a/tests/Stubs/Decidiq/Event/DecisionConcludedEvent.php b/tests/Stubs/Decidiq/Event/DecisionConcludedEvent.php
new file mode 100644
index 000000000..575d73f99
--- /dev/null
+++ b/tests/Stubs/Decidiq/Event/DecisionConcludedEvent.php
@@ -0,0 +1,169 @@
+
+ * @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.
+ *
+ * @link https://decidiq.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Decidiq\Event;
+
+use OCP\EventDispatcher\Event;
+
+/**
+ * Dispatched by decidiq when a Decision reaches a terminal state.
+ */
+class DecisionConcludedEvent extends Event {
+ /**
+ * Constructor.
+ *
+ * @param string $decisionId The decidiq decision id.
+ * @param string $decisionType The decision type slug.
+ * @param string $status approved|rejected|withdrawn|pending.
+ * @param string $outcome The outcome string.
+ * @param bool $signed Whether the decision was signed.
+ * @param string|null $signingReference The signing reference.
+ * @param array $signers The signers list.
+ * @param string|null $decidedAt The decided-at timestamp.
+ * @param string $sourceApp The originating app id.
+ * @param string|null $subjectRegister The subject register.
+ * @param string|null $subjectSchema The subject schema.
+ * @param string|null $subjectId The subject object id.
+ * @param string $externalReference The external reference.
+ * @param string $correlationId The correlation id.
+ */
+ public function __construct(
+ private readonly string $decisionId,
+ private readonly string $decisionType,
+ private readonly string $status,
+ private readonly string $outcome,
+ private readonly bool $signed = false,
+ private readonly ?string $signingReference = null,
+ private readonly array $signers = [],
+ private readonly ?string $decidedAt = null,
+ private readonly string $sourceApp = '',
+ private readonly ?string $subjectRegister = null,
+ private readonly ?string $subjectSchema = null,
+ private readonly ?string $subjectId = null,
+ private readonly string $externalReference = '',
+ private readonly string $correlationId = '',
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * @return string The decision id.
+ */
+ public function getDecisionId(): string {
+ return $this->decisionId;
+ }//end getDecisionId()
+
+ /**
+ * @return string The decision type.
+ */
+ public function getDecisionType(): string {
+ return $this->decisionType;
+ }//end getDecisionType()
+
+ /**
+ * @return string The status.
+ */
+ public function getStatus(): string {
+ return $this->status;
+ }//end getStatus()
+
+ /**
+ * @return string The outcome.
+ */
+ public function getOutcome(): string {
+ return $this->outcome;
+ }//end getOutcome()
+
+ /**
+ * @return bool Whether the decision was signed.
+ */
+ public function isSigned(): bool {
+ return $this->signed;
+ }//end isSigned()
+
+ /**
+ * @return string|null The signing reference.
+ */
+ public function getSigningReference(): ?string {
+ return $this->signingReference;
+ }//end getSigningReference()
+
+ /**
+ * @return array The signers.
+ */
+ public function getSigners(): array {
+ return $this->signers;
+ }//end getSigners()
+
+ /**
+ * @return string|null The decided-at timestamp.
+ */
+ public function getDecidedAt(): ?string {
+ return $this->decidedAt;
+ }//end getDecidedAt()
+
+ /**
+ * @return string The source app id.
+ */
+ public function getSourceApp(): string {
+ return $this->sourceApp;
+ }//end getSourceApp()
+
+ /**
+ * @return string|null The subject register.
+ */
+ public function getSubjectRegister(): ?string {
+ return $this->subjectRegister;
+ }//end getSubjectRegister()
+
+ /**
+ * @return string|null The subject schema.
+ */
+ public function getSubjectSchema(): ?string {
+ return $this->subjectSchema;
+ }//end getSubjectSchema()
+
+ /**
+ * @return string|null The subject id.
+ */
+ public function getSubjectId(): ?string {
+ return $this->subjectId;
+ }//end getSubjectId()
+
+ /**
+ * @return string The external reference.
+ */
+ public function getExternalReference(): string {
+ return $this->externalReference;
+ }//end getExternalReference()
+
+ /**
+ * @return string The correlation id.
+ */
+ public function getCorrelationId(): string {
+ return $this->correlationId;
+ }//end getCorrelationId()
+}//end class
diff --git a/tests/Stubs/Decidiq/Event/DecisionRequestedEvent.php b/tests/Stubs/Decidiq/Event/DecisionRequestedEvent.php
new file mode 100644
index 000000000..b618a049d
--- /dev/null
+++ b/tests/Stubs/Decidiq/Event/DecisionRequestedEvent.php
@@ -0,0 +1,180 @@
+
+ * @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.
+ *
+ * @link https://decidiq.nl
+ */
+
+declare(strict_types=1);
+
+namespace OCA\Decidiq\Event;
+
+use OCP\EventDispatcher\Event;
+
+/**
+ * Dispatched by a consuming app to request a decidiq Decision. The decidiq
+ * in-process listener writes the result back via setHandled()/setDecisionId().
+ */
+class DecisionRequestedEvent extends Event {
+
+ /**
+ * Whether decidiq handled the request.
+ */
+ private bool $handled = false;
+
+ /**
+ * The created decidiq decision id (null until handled).
+ */
+ private ?string $decisionId = null;
+
+ /**
+ * Constructor.
+ *
+ * @param string $sourceApp The requesting app id.
+ * @param string $subjectRegister The subject register.
+ * @param string $subjectSchema The subject schema.
+ * @param string $subjectId The subject object id.
+ * @param string $subjectLabel A human-readable subject label.
+ * @param string $decisionType The decision type slug.
+ * @param string $actorId The requesting actor id.
+ * @param array $payload The decision body payload.
+ * @param string $externalReference The external reference.
+ * @param string $correlationId The correlation id.
+ */
+ public function __construct(
+ private readonly string $sourceApp,
+ private readonly string $subjectRegister,
+ private readonly string $subjectSchema,
+ private readonly string $subjectId,
+ private readonly string $subjectLabel = '',
+ private readonly string $decisionType = 'contract',
+ private readonly string $actorId = '',
+ private readonly array $payload = [],
+ private readonly string $externalReference = '',
+ private readonly string $correlationId = '',
+ ) {
+ parent::__construct();
+ }//end __construct()
+
+ /**
+ * @return string The requesting app id.
+ */
+ public function getSourceApp(): string {
+ return $this->sourceApp;
+ }//end getSourceApp()
+
+ /**
+ * @return string The subject register.
+ */
+ public function getSubjectRegister(): string {
+ return $this->subjectRegister;
+ }//end getSubjectRegister()
+
+ /**
+ * @return string The subject schema.
+ */
+ public function getSubjectSchema(): string {
+ return $this->subjectSchema;
+ }//end getSubjectSchema()
+
+ /**
+ * @return string The subject object id.
+ */
+ public function getSubjectId(): string {
+ return $this->subjectId;
+ }//end getSubjectId()
+
+ /**
+ * @return string The subject label.
+ */
+ public function getSubjectLabel(): string {
+ return $this->subjectLabel;
+ }//end getSubjectLabel()
+
+ /**
+ * @return string The decision type slug.
+ */
+ public function getDecisionType(): string {
+ return $this->decisionType;
+ }//end getDecisionType()
+
+ /**
+ * @return string The requesting actor id.
+ */
+ public function getActorId(): string {
+ return $this->actorId;
+ }//end getActorId()
+
+ /**
+ * @return array The decision body payload.
+ */
+ public function getPayload(): array {
+ return $this->payload;
+ }//end getPayload()
+
+ /**
+ * @return string The external reference.
+ */
+ public function getExternalReference(): string {
+ return $this->externalReference;
+ }//end getExternalReference()
+
+ /**
+ * @return string The correlation id.
+ */
+ public function getCorrelationId(): string {
+ return $this->correlationId;
+ }//end getCorrelationId()
+
+ /**
+ * @return bool Whether decidiq handled the request.
+ */
+ public function isHandled(): bool {
+ return $this->handled;
+ }//end isHandled()
+
+ /**
+ * Mark the request handled (called by the decidiq listener).
+ *
+ * @param bool $handled Whether the request was handled.
+ *
+ * @return void
+ */
+ public function setHandled(bool $handled): void {
+ $this->handled = $handled;
+ }//end setHandled()
+
+ /**
+ * @return string|null The created decision id, or null.
+ */
+ public function getDecisionId(): ?string {
+ return $this->decisionId;
+ }//end getDecisionId()
+
+ /**
+ * Record the created decision id (called by the decidiq listener).
+ *
+ * @param string|null $decisionId The created decision id.
+ *
+ * @return void
+ */
+ public function setDecisionId(?string $decisionId): void {
+ $this->decisionId = $decisionId;
+ }//end setDecisionId()
+}//end class
diff --git a/tests/Stubs/Flow/IFlowNode.php b/tests/Stubs/Flow/IFlowNode.php
index e2d44a313..e31b98e4d 100644
--- a/tests/Stubs/Flow/IFlowNode.php
+++ b/tests/Stubs/Flow/IFlowNode.php
@@ -6,10 +6,10 @@
*
* Test stub for OpenRegister's published flow-node contract.
*
- * procest's nodes implement this interface, so without it the classes cannot be
+ * dossiq's nodes implement this interface, so without it the classes cannot be
* loaded in a unit test on an instance where OpenRegister is absent. Mirrors
* openregister lib/Service/Flow/IFlowNode.php — if that contract changes, this
- * stub is where procest finds out.
+ * stub is where dossiq finds out.
*
* @category Test
* @package OCA\OpenRegister\Service\Flow
diff --git a/tests/Stubs/Flow/RegisterFlowNodesEvent.php b/tests/Stubs/Flow/RegisterFlowNodesEvent.php
index a8c06d69f..73a73d89f 100644
--- a/tests/Stubs/Flow/RegisterFlowNodesEvent.php
+++ b/tests/Stubs/Flow/RegisterFlowNodesEvent.php
@@ -6,12 +6,12 @@
*
* Test stub for OpenRegister's node-registration collect event.
*
- * procest's ProcestFlowNodeListener is typed against it, so without this stub
+ * dossiq's DossiqFlowNodeListener is typed against it, so without this stub
* the listener cannot be loaded — and phpstan cannot resolve the
* `IEventListener` generic bound either.
*
* DELIBERATELY NOT A FAITHFUL COPY OF THE CONSTRUCTOR. The real event takes
- * OpenRegister's FlowNodeRegistry and hands each node straight to it; procest
+ * OpenRegister's FlowNodeRegistry and hands each node straight to it; dossiq
* has no such class and does not need one to prove it registers the right six.
* This stub collects them instead, which is exactly what a consumer-side test
* needs to assert.
diff --git a/tests/Unit/AppInfo/CrossAppEventNamesTest.php b/tests/Unit/AppInfo/CrossAppEventNamesTest.php
new file mode 100644
index 000000000..1636e1ccd
--- /dev/null
+++ b/tests/Unit/AppInfo/CrossAppEventNamesTest.php
@@ -0,0 +1,123 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Test
+ * @package OCA\Dossiq\Tests\Unit\AppInfo
+ * @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\AppInfo;
+
+use OCA\Dossiq\AppInfo\Registrar\WorkflowListenerRegistrar;
+use OCA\Dossiq\Service\ContractDecisionDelegationService;
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+
+/**
+ * A cross-app event FQN must name EVERY namespace the other app has used.
+ *
+ * This is the check that was missing. The decision app renamed its namespace
+ * from `OCA\Decidesk` to `OCA\Decidiq` with no compatibility alias, and dossiq
+ * named only the old spelling in two places. The consequences were different
+ * and both bad:
+ *
+ * - the listener registration is guarded by `class_exists`, so it went false
+ * and simply stopped registering. Every concluded decision quietly stopped
+ * materialising a ZGW Besluit. A guard that goes false looks exactly like
+ * the optional app not being installed, so nothing looked wrong.
+ * - the dispatch side fails CLOSED, so it threw "decidesk is not installed"
+ * on an instance where it was installed — blocking every contract decision
+ * behind a message pointing at the wrong problem.
+ *
+ * An app cannot move another app's class name; it can only follow it. So the
+ * property worth asserting is that these constants LIST the spellings rather
+ * than pin one.
+ */
+class CrossAppEventNamesTest extends TestCase {
+ /**
+ * Read a private class constant.
+ *
+ * @param string $class The class.
+ * @param string $name The constant name.
+ *
+ * @return array The value.
+ */
+ private function constant(string $class, string $name): array {
+ $value = (new ReflectionClass($class))->getConstant($name);
+ $this->assertIsArray($value, $name . ' must be a LIST of spellings, not a single string.');
+
+ return $value;
+ }
+
+ /**
+ * Both decision-event constants name the current namespace and the old one.
+ *
+ * @param string $class The class holding the constant.
+ * @param string $name The constant name.
+ *
+ * @return void
+ *
+ * @dataProvider crossAppEventConstants
+ */
+ public function testEachCrossAppEventListsEveryKnownNamespace(string $class, string $name): void {
+ $spellings = $this->constant(class: $class, name: $name);
+
+ $this->assertGreaterThanOrEqual(
+ 2,
+ count($spellings),
+ 'Pinning ONE spelling is what broke this: the other app renamed and this side stopped resolving.'
+ );
+
+ $haystack = implode(' ', $spellings);
+ $this->assertStringContainsString(
+ 'Decidiq',
+ $haystack,
+ 'The CURRENT namespace must be listed, or the integration is dead on a renamed instance.'
+ );
+ $this->assertStringContainsString(
+ 'Decidesk',
+ $haystack,
+ 'The OLD namespace must stay listed until no supported install ships it — otherwise the '
+ . 'integration breaks in the other direction during a staggered upgrade.'
+ );
+ }
+
+ /**
+ * The newest spelling is tried FIRST.
+ *
+ * Both resolve by "first one that exists", so ordering decides which is used
+ * on an instance carrying both — and that should be the current one.
+ *
+ * @param string $class The class holding the constant.
+ * @param string $name The constant name.
+ *
+ * @return void
+ *
+ * @dataProvider crossAppEventConstants
+ */
+ public function testTheCurrentNamespaceIsPreferred(string $class, string $name): void {
+ $spellings = $this->constant(class: $class, name: $name);
+
+ $this->assertStringContainsString('Decidiq', $spellings[0]);
+ }
+
+ /**
+ * The constants that carry a cross-app event name.
+ *
+ * @return array> The cases.
+ */
+ public static function crossAppEventConstants(): array {
+ return [
+ [WorkflowListenerRegistrar::class, 'DECISION_CONCLUDED_EVENTS'],
+ [ContractDecisionDelegationService::class, 'DECISION_REQUESTED_EVENTS'],
+ ];
+ }
+}
diff --git a/tests/Unit/AppInfo/RoutePlaceholderBindingTest.php b/tests/Unit/AppInfo/RoutePlaceholderBindingTest.php
new file mode 100644
index 000000000..cbf843972
--- /dev/null
+++ b/tests/Unit/AppInfo/RoutePlaceholderBindingTest.php
@@ -0,0 +1,164 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Test
+ * @package OCA\Dossiq\Tests\Unit\AppInfo
+ * @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\AppInfo;
+
+use PHPUnit\Framework\TestCase;
+use ReflectionClass;
+use ReflectionNamedType;
+
+/**
+ * Every URL placeholder must be named the same as the argument it binds to.
+ *
+ * Nextcloud's Dispatcher resolves a controller argument BY NAME —
+ * `$this->request->getParam($param, $default)` — never by position. So a route
+ * declaring `{voorstelId}` against a method signing `string $proposalId` binds
+ * null, the typehint throws a TypeError, and the request is answered HTTP 400.
+ * For any input. On every call.
+ *
+ * Five routes were in exactly that state, left behind by the Dutch→English
+ * vocabulary sweep, which renamed the method parameters and not the URLs. NO
+ * existing check saw it: the route exists, the controller exists, the method
+ * exists, and gate-6 (route-reachability) verifies precisely those three things.
+ * Only the NAMES disagreed, and a name is not something any of them compared.
+ *
+ * This is the check that compares them.
+ */
+class RoutePlaceholderBindingTest extends TestCase {
+ /**
+ * Controller-name suffixes that never take a bound URL argument.
+ *
+ * @var array
+ */
+ private const CONTROLLER_NS = 'OCA\\Dossiq\\Controller\\';
+
+ /**
+ * Load the declared routes.
+ *
+ * @return array> The `routes` list.
+ */
+ private function routes(): array {
+ $declared = require __DIR__ . '/../../../appinfo/routes.php';
+ $this->assertIsArray($declared, 'appinfo/routes.php did not return an array.');
+ $this->assertArrayHasKey('routes', $declared);
+
+ return $declared['routes'];
+ }
+
+ /**
+ * Turn `parafeerRoute#start` into its controller class and method.
+ *
+ * Nextcloud's own convention: the part before `#` is the controller in
+ * lowerCamelCase, optionally namespaced with `\`, and gains a `Controller`
+ * suffix.
+ *
+ * @param string $name The route's `name` value.
+ *
+ * @return array{0: string, 1: string}|null Class and method, or null when unresolvable.
+ */
+ private function target(string $name): ?array {
+ if (str_contains($name, '#') === false) {
+ return null;
+ }
+
+ [$controller, $method] = explode('#', $name, 2);
+ $class = self::CONTROLLER_NS . str_replace('\\', '\\', ucfirst($controller)) . 'Controller';
+ if (class_exists($class) === false) {
+ return null;
+ }
+
+ return [$class, $method];
+ }
+
+ /**
+ * Extract `{placeholder}` names from a route URL.
+ *
+ * @param string $url The route URL.
+ *
+ * @return array The placeholder names, in order.
+ */
+ private function placeholders(string $url): array {
+ $matches = [];
+ preg_match_all('/\{([A-Za-z_][A-Za-z0-9_]*)\}/', $url, $matches);
+
+ return $matches[1];
+ }
+
+ /**
+ * Every placeholder names a parameter the target method actually declares.
+ *
+ * A placeholder that no parameter answers to is only harmless when the
+ * method takes it off the request itself; a placeholder bound to a
+ * NON-NULLABLE scalar parameter that does not exist is the 400.
+ *
+ * @return void
+ */
+ public function testEveryPlaceholderBindsToADeclaredParameter(): void {
+ $offenders = [];
+
+ foreach ($this->routes() as $route) {
+ $name = (string)($route['name'] ?? '');
+ $url = (string)($route['url'] ?? '');
+ $placeholders = $this->placeholders(url: $url);
+ if ($placeholders === []) {
+ continue;
+ }
+
+ $target = $this->target(name: $name);
+ if ($target === null) {
+ continue;
+ }
+
+ [$class, $method] = $target;
+ $reflection = new ReflectionClass($class);
+ if ($reflection->hasMethod($method) === false) {
+ continue;
+ }
+
+ $declared = [];
+ $required = [];
+ foreach ($reflection->getMethod($method)->getParameters() as $parameter) {
+ $declared[] = $parameter->getName();
+ $type = $parameter->getType();
+ $nullable = ($type instanceof ReflectionNamedType) ? $type->allowsNull() : true;
+ if ($parameter->isDefaultValueAvailable() === false && $nullable === false) {
+ $required[] = $parameter->getName();
+ }
+ }
+
+ // A method with a required, non-nullable parameter that NO placeholder
+ // names cannot be satisfied from the URL. That is the 400.
+ foreach ($required as $parameterName) {
+ if (in_array($parameterName, $placeholders, true) === false) {
+ $offenders[] = sprintf(
+ '%s (%s) requires $%s, but the URL offers {%s}',
+ $name,
+ $url,
+ $parameterName,
+ implode('}, {', $placeholders)
+ );
+ }
+ }
+ }
+
+ $this->assertSame(
+ [],
+ $offenders,
+ "These routes answer HTTP 400 for every request — the Dispatcher binds by NAME:\n"
+ . implode("\n", $offenders)
+ );
+ }
+}
diff --git a/tests/Unit/Controller/ParafeerRouteControllerContractTest.php b/tests/Unit/Controller/ParafeerRouteControllerContractTest.php
deleted file mode 100644
index 8c708060f..000000000
--- a/tests/Unit/Controller/ParafeerRouteControllerContractTest.php
+++ /dev/null
@@ -1,233 +0,0 @@
-
- * @copyright 2026 Conduction B.V.
- * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
- *
- * @link https://conduction.nl
- */
-
-declare(strict_types=1);
-
-namespace OCA\Dossiq\Tests\Unit\Controller;
-
-use OCA\Dossiq\Controller\ParafeerRouteController;
-use OCA\Dossiq\Service\ParafeerRouteService;
-use OCP\AppFramework\Http;
-use OCP\IGroupManager;
-use OCP\IRequest;
-use OCP\IUser;
-use OCP\IUserSession;
-use PHPUnit\Framework\MockObject\MockObject;
-use PHPUnit\Framework\TestCase;
-use Psr\Log\LoggerInterface;
-
-/**
- * Wire-contract tests for ParafeerRouteController.
- *
- * @covers \OCA\Dossiq\Controller\ParafeerRouteController
- */
-class ParafeerRouteControllerContractTest extends TestCase {
-
- /**
- * The IRequest mock.
- *
- * @var IRequest|MockObject
- */
- private IRequest $request;
-
- /**
- * The parafering engine service.
- *
- * @var ParafeerRouteService|MockObject
- */
- private ParafeerRouteService $routeService;
-
- /**
- * The user session.
- *
- * @var IUserSession|MockObject
- */
- private IUserSession $userSession;
-
- /**
- * The group manager (admin check, used only by skipStep()).
- *
- * @var IGroupManager|MockObject
- */
- private IGroupManager $groupManager;
-
- /**
- * The logger.
- *
- * @var LoggerInterface|MockObject
- */
- private LoggerInterface $logger;
-
- /**
- * The controller under test.
- *
- * @var ParafeerRouteController
- */
- private ParafeerRouteController $controller;
-
- /**
- * Build the controller with mocked collaborators.
- *
- * @return void
- */
- protected function setUp(): void {
- parent::setUp();
-
- $this->request = $this->createMock(IRequest::class);
- $this->routeService = $this->createMock(ParafeerRouteService::class);
- $this->userSession = $this->createMock(IUserSession::class);
- $this->groupManager = $this->createMock(IGroupManager::class);
- $this->logger = $this->createMock(LoggerInterface::class);
-
- $this->controller = new ParafeerRouteController(
- appName: 'dossiq',
- request: $this->request,
- routeService: $this->routeService,
- userSession: $this->userSession,
- groupManager: $this->groupManager,
- logger: $this->logger,
- );
- }//end setUp()
-
- /**
- * Put a signed-in, non-admin user on the session.
- *
- * @return void
- */
- private function signIn(): void {
- $user = $this->createMock(IUser::class);
- $user->method('getUID')->willReturn('paraferende-ambtenaar');
- $this->userSession->method('getUser')->willReturn($user);
- $this->groupManager->method('isAdmin')->willReturn(false);
- }//end signIn()
-
- /**
- * Neither engine action may run without a session.
- *
- * @return void
- */
- public function testBothEngineActionsRefuseAnUnauthenticatedCallerBeforeTouchingTheRoute(): void {
- $this->userSession->method('getUser')->willReturn(null);
- $this->routeService->expects($this->never())->method('completeStep');
- $this->routeService->expects($this->never())->method('addAdhocStep');
-
- $complete = $this->controller->completeStep(proposalId: 'voorstel-1');
- $add = $this->controller->addStep(proposalId: 'voorstel-1');
-
- $this->assertSame(Http::STATUS_UNAUTHORIZED, $complete->getStatus());
- $this->assertSame(['error' => 'Authenticatie vereist'], $complete->getData());
- $this->assertSame(Http::STATUS_UNAUTHORIZED, $add->getStatus());
- $this->assertSame(['error' => 'Authenticatie vereist'], $add->getData());
- }//end testBothEngineActionsRefuseAnUnauthenticatedCallerBeforeTouchingTheRoute()
-
- /**
- * completeStep acts on the voorstel from the URL and returns the engine's
- * result untouched.
- *
- * @return void
- */
- public function testCompleteStepCompletesTheStepOnTheVoorstelFromTheUrl(): void {
- $this->signIn();
- $result = ['step' => 2, 'status' => 'completed'];
-
- $this->routeService->expects($this->once())
- ->method('completeStep')
- ->with('voorstel-77', [])
- ->willReturn($result);
-
- $response = $this->controller->completeStep(proposalId: 'voorstel-77');
-
- $this->assertSame(Http::STATUS_OK, $response->getStatus());
- $this->assertSame($result, $response->getData());
- }//end testCompleteStepCompletesTheStepOnTheVoorstelFromTheUrl()
-
- /**
- * An engine failure on completeStep is a 500 with a generic message that
- * does not leak the internal exception text.
- *
- * @return void
- */
- public function testCompleteStepReturns500WithoutLeakingTheEngineFailure(): void {
- $this->signIn();
-
- $this->routeService->method('completeStep')
- ->willThrowException(new \RuntimeException('SQLSTATE[23000] duplicate key parafeerroute_pk'));
-
- $response = $this->controller->completeStep(proposalId: 'voorstel-77');
-
- $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
- $this->assertSame(['error' => 'Stap kon niet worden voltooid'], $response->getData());
- }//end testCompleteStepReturns500WithoutLeakingTheEngineFailure()
-
- /**
- * addStep forwards typed defaults, never nulls, into the engine's
- * `int $afterStep` / `array $stepData` parameters.
- *
- * @return void
- */
- public function testAddStepForwardsTypedDefaultsForAnEmptyBody(): void {
- $this->signIn();
- $snapshot = ['steps' => [['order' => 1], ['order' => 2]]];
-
- $this->routeService->expects($this->once())
- ->method('addAdhocStep')
- ->with('voorstel-77', $this->identicalTo(0), $this->identicalTo([]))
- ->willReturn($snapshot);
-
- $response = $this->controller->addStep(proposalId: 'voorstel-77');
-
- $this->assertSame(Http::STATUS_OK, $response->getStatus());
- $this->assertSame($snapshot, $response->getData());
- }//end testAddStepForwardsTypedDefaultsForAnEmptyBody()
-
- /**
- * An engine failure on addStep is a 500 with its own generic message,
- * distinct from the completeStep one.
- *
- * @return void
- */
- public function testAddStepReturns500WithItsOwnGenericMessage(): void {
- $this->signIn();
-
- $this->routeService->method('addAdhocStep')
- ->willThrowException(new \RuntimeException('boom'));
-
- $response = $this->controller->addStep(proposalId: 'voorstel-77');
-
- $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
- $this->assertSame(['error' => 'Stap toevoegen mislukt'], $response->getData());
- }//end testAddStepReturns500WithItsOwnGenericMessage()
-}//end class
diff --git a/tests/Unit/Flow/DossiqActionNodeTest.php b/tests/Unit/Flow/DossiqActionNodeTest.php
new file mode 100644
index 000000000..3ee3a88ba
--- /dev/null
+++ b/tests/Unit/Flow/DossiqActionNodeTest.php
@@ -0,0 +1,237 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Test
+ * @package OCA\Dossiq\Tests\Unit\Flow
+ * @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\Flow;
+
+use OCA\Dossiq\Flow\DossiqSendEmailNode;
+use OCA\Dossiq\Service\Actions\ActionResult;
+use OCA\Dossiq\Service\Actions\SendEmailHandler;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use PHPUnit\Framework\TestCase;
+use UnexpectedValueException;
+
+/**
+ * Covers the action-node wrapper.
+ *
+ * SendEmail stands in for all six — they differ only in their handler and their
+ * required keys, and the behaviour worth pinning lives in the shared base.
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+class DossiqActionNodeTest extends TestCase {
+
+ /**
+ * @var SendEmailHandler&\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $handler;
+
+ /**
+ * @var DossiqSendEmailNode
+ */
+ private DossiqSendEmailNode $node;
+
+
+ /**
+ * Set up the node over a mocked handler.
+ *
+ * @return void
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->handler = $this->createMock(SendEmailHandler::class);
+ $this->handler->method('type')->willReturn('sendEmail');
+
+ $l10n = $this->createMock(IL10N::class);
+ $l10n->method('t')->willReturnCallback(
+ static function (string $text, array $params=[]): string {
+ return vsprintf($text, $params);
+ }
+ );
+ $urls = $this->createMock(IURLGenerator::class);
+ $urls->method('imagePath')->willReturn('/apps/dossiq/img/app-dark.svg');
+
+ $this->node = new DossiqSendEmailNode($this->handler, $l10n, $urls);
+
+ }//end setUp()
+
+
+ /**
+ * A complete config for this node.
+ *
+ * @return array The config.
+ */
+ private function config(): array {
+ return [
+ 'recipientRef' => 'behandelaar',
+ 'subjectTemplate' => 'Zaak {{ title }}',
+ 'bodyTemplate' => 'Uw zaak is bijgewerkt.',
+ ];
+
+ }//end config()
+
+
+ /**
+ * The node id is derived from the handler's own type slug.
+ *
+ * Deriving it is what stops a node id drifting from the handler it runs,
+ * and gives the reference migration one rule instead of six.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testIdIsDerivedFromTheHandlerType(): void {
+ // `dossiq.action.*`, not `dossiq.*`: the LIVE transition vocabulary
+ // owns the plain names and both systems ship a sendEmail. An id
+ // collision here would have one handler silently shadow the other in
+ // the catalogue.
+ $this->assertSame('dossiq.action.sendEmail', $this->node->getId());
+
+ }//end testIdIsDerivedFromTheHandlerType()
+
+
+ /**
+ * A successful action puts its data on the item.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testSuccessfulActionWritesItsResultOntoTheItem(): void {
+ $this->handler->method('handle')->willReturn(new ActionResult(true, null, ['sent' => 1]));
+
+ $out = $this->node->execute(
+ [['json' => ['id' => 'case-1', 'title' => 'Bezwaar']]],
+ $this->config(),
+ []
+ );
+
+ $this->assertCount(1, $out);
+ $this->assertSame(['sent' => 1], $out[0]['json']['actionResult']);
+ $this->assertSame('case-1', $out[0]['json']['id']);
+
+ }//end testSuccessfulActionWritesItsResultOntoTheItem()
+
+
+ /**
+ * The output key is configurable.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testOutputKeyIsConfigurable(): void {
+ $this->handler->method('handle')->willReturn(new ActionResult(true, null, ['sent' => 1]));
+
+ $out = $this->node->execute(
+ [['json' => []]],
+ array_merge($this->config(), ['output' => 'mailResult']),
+ []
+ );
+
+ $this->assertArrayHasKey('mailResult', $out[0]['json']);
+
+ }//end testOutputKeyIsConfigurable()
+
+
+ /**
+ * A FAILED action throws instead of passing the item through.
+ *
+ * This is the one that matters. Returning the item unchanged would leave
+ * the output key absent, and a downstream router would take its default
+ * branch exactly as though the action had succeeded — the engine's onError
+ * policy only ever sees failures that propagate out of execute().
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testFailedActionThrowsRatherThanPassingThrough(): void {
+ $this->handler->method('handle')->willReturn(new ActionResult(false, 'smtp_unavailable'));
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('smtp_unavailable');
+
+ $this->node->execute([['json' => []]], $this->config(), []);
+
+ }//end testFailedActionThrowsRatherThanPassingThrough()
+
+
+ /**
+ * A config missing a required key is rejected at EXECUTION, not only on save.
+ *
+ * validateConfig() runs when a flow is saved; a seeded or imported flow
+ * reaches execute() without ever having been saved through the editor.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testUnvalidatedConfigIsRejectedAtExecution(): void {
+ $this->handler->expects($this->never())->method('handle');
+
+ $config = $this->config();
+ unset($config['recipientRef']);
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->node->execute([['json' => []]], $config, []);
+
+ }//end testUnvalidatedConfigIsRejectedAtExecution()
+
+
+ /**
+ * validateConfig() names the key it is missing.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testValidateConfigNamesTheMissingKey(): void {
+ $config = $this->config();
+ unset($config['bodyTemplate']);
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('bodyTemplate');
+
+ $this->node->validateConfig($config);
+
+ }//end testValidateConfigNamesTheMissingKey()
+
+
+ /**
+ * Every item in the batch is acted on.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testEveryItemInTheBatchIsActedOn(): void {
+ $this->handler->expects($this->exactly(3))->method('handle')
+ ->willReturn(new ActionResult(true, null, []));
+
+ $out = $this->node->execute(
+ [['json' => ['id' => 'a']], ['json' => ['id' => 'b']], ['json' => ['id' => 'c']]],
+ $this->config(),
+ []
+ );
+
+ $this->assertCount(3, $out);
+
+ }//end testEveryItemInTheBatchIsActedOn()
+
+
+}//end class
diff --git a/tests/Unit/Flow/DossiqFlowNodeListenerTest.php b/tests/Unit/Flow/DossiqFlowNodeListenerTest.php
new file mode 100644
index 000000000..e2dec307a
--- /dev/null
+++ b/tests/Unit/Flow/DossiqFlowNodeListenerTest.php
@@ -0,0 +1,181 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Test
+ * @package OCA\Dossiq\Tests\Unit\Flow
+ * @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\Flow;
+
+use OCA\OpenRegister\Service\Flow\IFlowNode;
+use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent;
+use OCA\Dossiq\Flow\DossiqCallWebhookNode;
+use OCA\Dossiq\Flow\DossiqCreateDocumentNode;
+use OCA\Dossiq\Flow\DossiqFlowNodeListener;
+use OCA\Dossiq\Flow\DossiqMergeTemplateNode;
+use OCA\Dossiq\Flow\DossiqNotifyRoleNode;
+use OCA\Dossiq\Flow\DossiqScheduleReminderNode;
+use OCA\Dossiq\Flow\DossiqSendEmailNode;
+use OCA\Dossiq\Flow\DossiqTxSendEmailNode;
+use OCA\Dossiq\Flow\DossiqTxCreateTaskNode;
+use OCA\Dossiq\Flow\DossiqTxCreateSubCaseNode;
+use OCA\Dossiq\Flow\DossiqTxWebhookNode;
+use OCA\Dossiq\Flow\DossiqTxSetFieldNode;
+use OCA\Dossiq\Flow\DossiqTxNotifyNode;
+use OCA\Dossiq\Flow\DossiqTxBesluitvormingActivateNode;
+use OCA\Dossiq\Flow\DossiqTxBesluitvormingPublishNode;
+use OCA\Dossiq\Flow\DossiqTxEvaluateDecisionNode;
+use OCP\EventDispatcher\Event;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
+
+/**
+ * Proves dossiq actually contributes all six case actions.
+ *
+ * A node class that exists but is never registered is invisible to the flow
+ * editor — and looks identical to one that works, right up until somebody tries
+ * to build a flow with it.
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+class DossiqFlowNodeListenerTest extends TestCase {
+
+ /**
+ * The id each node class reports, in the order the listener registers them.
+ *
+ * @var array
+ */
+ private const EXPECTED_IDS = [
+ DossiqTxSendEmailNode::class => 'dossiq.sendEmail',
+ DossiqTxCreateTaskNode::class => 'dossiq.createTask',
+ DossiqTxCreateSubCaseNode::class => 'dossiq.createSubCase',
+ DossiqTxWebhookNode::class => 'dossiq.webhook',
+ DossiqTxSetFieldNode::class => 'dossiq.setField',
+ DossiqTxNotifyNode::class => 'dossiq.notify',
+ DossiqTxBesluitvormingActivateNode::class => 'dossiq.besluitvormingActivate',
+ DossiqTxBesluitvormingPublishNode::class => 'dossiq.besluitvormingPublish',
+ DossiqTxEvaluateDecisionNode::class => 'dossiq.evaluateDecision',
+ DossiqSendEmailNode::class => 'dossiq.action.sendEmail',
+ DossiqNotifyRoleNode::class => 'dossiq.action.notifyRole',
+ DossiqCallWebhookNode::class => 'dossiq.action.callWebhook',
+ DossiqCreateDocumentNode::class => 'dossiq.action.createDocument',
+ DossiqMergeTemplateNode::class => 'dossiq.action.mergeTemplate',
+ DossiqScheduleReminderNode::class => 'dossiq.action.scheduleReminder',
+ ];
+
+
+
+ /**
+ * Build the listener over a container that yields id-reporting nodes.
+ *
+ * The listener resolves its nodes from a class-string list, so the test
+ * asserts what reaches the CATALOGUE rather than what was injected — which
+ * is the thing that actually matters: a node class that exists but never
+ * registers is invisible to the flow editor and looks identical to one that
+ * works.
+ *
+ * @param string[] $failing Class names the container should refuse to build.
+ *
+ * @return DossiqFlowNodeListener The listener under test.
+ */
+ private function listener(array $failing=[]): DossiqFlowNodeListener {
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willReturnCallback(
+ function (string $class) use ($failing): IFlowNode {
+ if (in_array($class, $failing, true) === true) {
+ throw new RuntimeException('cannot construct ' . $class);
+ }
+
+ $node = $this->createMock(IFlowNode::class);
+ $node->method('getId')->willReturn(self::EXPECTED_IDS[$class]);
+ return $node;
+ }
+ );
+
+ return new DossiqFlowNodeListener($container, $this->createMock(LoggerInterface::class));
+
+ }//end listener()
+
+
+ /**
+ * All fifteen actions land on the catalogue — both vocabularies.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testAllFifteenActionsAreRegistered(): void {
+ $event = new RegisterFlowNodesEvent();
+ $this->listener()->handle($event);
+
+ $ids = array_map(
+ static fn ($node): string => $node->getId(),
+ $event->getRegisteredNodes()
+ );
+
+ $this->assertSame(
+ array_values(self::EXPECTED_IDS),
+ $ids
+ );
+
+ }//end testAllFifteenActionsAreRegistered()
+
+
+ /**
+ * An unrelated event is ignored rather than half-handled.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testUnrelatedEventIsIgnored(): void {
+ $other = new class extends Event {
+ };
+
+ $this->listener()->handle($other);
+
+ $this->addToAssertionCount(1);
+
+ }//end testUnrelatedEventIsIgnored()
+
+ /**
+ * One unbuildable node does not cost the other fourteen their place.
+ *
+ * The list-based resolution introduced this branch: if a single node's
+ * dependencies cannot be constructed, aborting would empty the whole
+ * catalogue. A skipped node is visible — the editor simply does not offer
+ * it — where a failed registration takes everything down with it.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testOneUnbuildableNodeDoesNotCostTheRest(): void {
+ $event = new RegisterFlowNodesEvent();
+ $this->listener(failing: [DossiqTxSetFieldNode::class])->handle($event);
+
+ $ids = array_map(
+ static fn ($node): string => $node->getId(),
+ $event->getRegisteredNodes()
+ );
+
+ $this->assertCount(14, $ids);
+ $this->assertNotContains('dossiq.setField', $ids);
+ $this->assertContains('dossiq.sendEmail', $ids);
+ $this->assertContains('dossiq.action.sendEmail', $ids);
+
+ }//end testOneUnbuildableNodeDoesNotCostTheRest()
+
+
+}//end class
diff --git a/tests/Unit/Flow/ProcestActionNodeTest.php b/tests/Unit/Flow/ProcestActionNodeTest.php
deleted file mode 100644
index 3218cbb25..000000000
--- a/tests/Unit/Flow/ProcestActionNodeTest.php
+++ /dev/null
@@ -1,227 +0,0 @@
-
- * SPDX-License-Identifier: EUPL-1.2
- *
- * @category Test
- * @package OCA\Dossiq\Tests\Unit\Flow
- * @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\Flow;
-
-use OCA\Dossiq\Flow\ProcestSendEmailNode;
-use OCA\Dossiq\Service\Actions\ActionResult;
-use OCA\Dossiq\Service\Actions\SendEmailHandler;
-use OCP\IL10N;
-use OCP\IURLGenerator;
-use PHPUnit\Framework\TestCase;
-use UnexpectedValueException;
-
-/**
- * Covers the action-node wrapper.
- *
- * SendEmail stands in for all six — they differ only in their handler and their
- * required keys, and the behaviour worth pinning lives in the shared base.
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
-class ProcestActionNodeTest extends TestCase {
-
- /**
- * @var SendEmailHandler&\PHPUnit\Framework\MockObject\MockObject
- */
- private $handler;
-
- /**
- * @var ProcestSendEmailNode
- */
- private ProcestSendEmailNode $node;
-
- /**
- * Set up the node over a mocked handler.
- *
- * @return void
- */
- protected function setUp(): void {
- parent::setUp();
- $this->handler = $this->createMock(SendEmailHandler::class);
- $this->handler->method('type')->willReturn('sendEmail');
-
- $l10n = $this->createMock(IL10N::class);
- $l10n->method('t')->willReturnCallback(
- static function (string $text, array $params = []): string {
- return vsprintf($text, $params);
- }
- );
- $urls = $this->createMock(IURLGenerator::class);
- $urls->method('imagePath')->willReturn('/apps/procest/img/app-dark.svg');
-
- $this->node = new ProcestSendEmailNode($this->handler, $l10n, $urls);
-
- }//end setUp()
-
- /**
- * A complete config for this node.
- *
- * @return array The config.
- */
- private function config(): array {
- return [
- 'recipientRef' => 'behandelaar',
- 'subjectTemplate' => 'Zaak {{ title }}',
- 'bodyTemplate' => 'Uw zaak is bijgewerkt.',
- ];
-
- }//end config()
-
- /**
- * The node id is derived from the handler's own type slug.
- *
- * Deriving it is what stops a node id drifting from the handler it runs,
- * and gives the reference migration one rule instead of six.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testIdIsDerivedFromTheHandlerType(): void {
- // `procest.action.*`, not `procest.*`: the LIVE transition vocabulary
- // owns the plain names and both systems ship a sendEmail. An id
- // collision here would have one handler silently shadow the other in
- // the catalogue.
- $this->assertSame('procest.action.sendEmail', $this->node->getId());
-
- }//end testIdIsDerivedFromTheHandlerType()
-
- /**
- * A successful action puts its data on the item.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testSuccessfulActionWritesItsResultOntoTheItem(): void {
- $this->handler->method('handle')->willReturn(new ActionResult(true, null, ['sent' => 1]));
-
- $out = $this->node->execute(
- [['json' => ['id' => 'case-1', 'title' => 'Bezwaar']]],
- $this->config(),
- []
- );
-
- $this->assertCount(1, $out);
- $this->assertSame(['sent' => 1], $out[0]['json']['actionResult']);
- $this->assertSame('case-1', $out[0]['json']['id']);
-
- }//end testSuccessfulActionWritesItsResultOntoTheItem()
-
- /**
- * The output key is configurable.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testOutputKeyIsConfigurable(): void {
- $this->handler->method('handle')->willReturn(new ActionResult(true, null, ['sent' => 1]));
-
- $out = $this->node->execute(
- [['json' => []]],
- array_merge($this->config(), ['output' => 'mailResult']),
- []
- );
-
- $this->assertArrayHasKey('mailResult', $out[0]['json']);
-
- }//end testOutputKeyIsConfigurable()
-
- /**
- * A FAILED action throws instead of passing the item through.
- *
- * This is the one that matters. Returning the item unchanged would leave
- * the output key absent, and a downstream router would take its default
- * branch exactly as though the action had succeeded — the engine's onError
- * policy only ever sees failures that propagate out of execute().
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testFailedActionThrowsRatherThanPassingThrough(): void {
- $this->handler->method('handle')->willReturn(new ActionResult(false, 'smtp_unavailable'));
-
- $this->expectException(UnexpectedValueException::class);
- $this->expectExceptionMessage('smtp_unavailable');
-
- $this->node->execute([['json' => []]], $this->config(), []);
-
- }//end testFailedActionThrowsRatherThanPassingThrough()
-
- /**
- * A config missing a required key is rejected at EXECUTION, not only on save.
- *
- * validateConfig() runs when a flow is saved; a seeded or imported flow
- * reaches execute() without ever having been saved through the editor.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testUnvalidatedConfigIsRejectedAtExecution(): void {
- $this->handler->expects($this->never())->method('handle');
-
- $config = $this->config();
- unset($config['recipientRef']);
-
- $this->expectException(UnexpectedValueException::class);
- $this->node->execute([['json' => []]], $config, []);
-
- }//end testUnvalidatedConfigIsRejectedAtExecution()
-
- /**
- * validateConfig() names the key it is missing.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testValidateConfigNamesTheMissingKey(): void {
- $config = $this->config();
- unset($config['bodyTemplate']);
-
- $this->expectException(UnexpectedValueException::class);
- $this->expectExceptionMessage('bodyTemplate');
-
- $this->node->validateConfig($config);
-
- }//end testValidateConfigNamesTheMissingKey()
-
- /**
- * Every item in the batch is acted on.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testEveryItemInTheBatchIsActedOn(): void {
- $this->handler->expects($this->exactly(3))->method('handle')
- ->willReturn(new ActionResult(true, null, []));
-
- $out = $this->node->execute(
- [['json' => ['id' => 'a']], ['json' => ['id' => 'b']], ['json' => ['id' => 'c']]],
- $this->config(),
- []
- );
-
- $this->assertCount(3, $out);
-
- }//end testEveryItemInTheBatchIsActedOn()
-
-}//end class
diff --git a/tests/Unit/Flow/ProcestFlowNodeListenerTest.php b/tests/Unit/Flow/ProcestFlowNodeListenerTest.php
deleted file mode 100644
index a074a86f3..000000000
--- a/tests/Unit/Flow/ProcestFlowNodeListenerTest.php
+++ /dev/null
@@ -1,175 +0,0 @@
-
- * SPDX-License-Identifier: EUPL-1.2
- *
- * @category Test
- * @package OCA\Dossiq\Tests\Unit\Flow
- * @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\Flow;
-
-use OCA\Dossiq\Flow\ProcestCallWebhookNode;
-use OCA\Dossiq\Flow\ProcestCreateDocumentNode;
-use OCA\Dossiq\Flow\ProcestFlowNodeListener;
-use OCA\Dossiq\Flow\ProcestMergeTemplateNode;
-use OCA\Dossiq\Flow\ProcestNotifyRoleNode;
-use OCA\Dossiq\Flow\ProcestScheduleReminderNode;
-use OCA\Dossiq\Flow\ProcestSendEmailNode;
-use OCA\Dossiq\Flow\ProcestTxBesluitvormingActivateNode;
-use OCA\Dossiq\Flow\ProcestTxBesluitvormingPublishNode;
-use OCA\Dossiq\Flow\ProcestTxCreateSubCaseNode;
-use OCA\Dossiq\Flow\ProcestTxCreateTaskNode;
-use OCA\Dossiq\Flow\ProcestTxEvaluateDecisionNode;
-use OCA\Dossiq\Flow\ProcestTxNotifyNode;
-use OCA\Dossiq\Flow\ProcestTxSendEmailNode;
-use OCA\Dossiq\Flow\ProcestTxSetFieldNode;
-use OCA\Dossiq\Flow\ProcestTxWebhookNode;
-use OCA\OpenRegister\Service\Flow\IFlowNode;
-use OCA\OpenRegister\Service\Flow\RegisterFlowNodesEvent;
-use OCP\EventDispatcher\Event;
-use PHPUnit\Framework\TestCase;
-use Psr\Container\ContainerInterface;
-use Psr\Log\LoggerInterface;
-use RuntimeException;
-
-/**
- * Proves procest actually contributes all six case actions.
- *
- * A node class that exists but is never registered is invisible to the flow
- * editor — and looks identical to one that works, right up until somebody tries
- * to build a flow with it.
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
-class ProcestFlowNodeListenerTest extends TestCase {
-
- /**
- * The id each node class reports, in the order the listener registers them.
- *
- * @var array
- */
- private const EXPECTED_IDS = [
- ProcestTxSendEmailNode::class => 'procest.sendEmail',
- ProcestTxCreateTaskNode::class => 'procest.createTask',
- ProcestTxCreateSubCaseNode::class => 'procest.createSubCase',
- ProcestTxWebhookNode::class => 'procest.webhook',
- ProcestTxSetFieldNode::class => 'procest.setField',
- ProcestTxNotifyNode::class => 'procest.notify',
- ProcestTxBesluitvormingActivateNode::class => 'procest.besluitvormingActivate',
- ProcestTxBesluitvormingPublishNode::class => 'procest.besluitvormingPublish',
- ProcestTxEvaluateDecisionNode::class => 'procest.evaluateDecision',
- ProcestSendEmailNode::class => 'procest.action.sendEmail',
- ProcestNotifyRoleNode::class => 'procest.action.notifyRole',
- ProcestCallWebhookNode::class => 'procest.action.callWebhook',
- ProcestCreateDocumentNode::class => 'procest.action.createDocument',
- ProcestMergeTemplateNode::class => 'procest.action.mergeTemplate',
- ProcestScheduleReminderNode::class => 'procest.action.scheduleReminder',
- ];
-
- /**
- * Build the listener over a container that yields id-reporting nodes.
- *
- * The listener resolves its nodes from a class-string list, so the test
- * asserts what reaches the CATALOGUE rather than what was injected — which
- * is the thing that actually matters: a node class that exists but never
- * registers is invisible to the flow editor and looks identical to one that
- * works.
- *
- * @param string[] $failing Class names the container should refuse to build.
- *
- * @return ProcestFlowNodeListener The listener under test.
- */
- private function listener(array $failing = []): ProcestFlowNodeListener {
- $container = $this->createMock(ContainerInterface::class);
- $container->method('get')->willReturnCallback(
- function (string $class) use ($failing): IFlowNode {
- if (in_array($class, $failing, true) === true) {
- throw new RuntimeException('cannot construct ' . $class);
- }
-
- $node = $this->createMock(IFlowNode::class);
- $node->method('getId')->willReturn(self::EXPECTED_IDS[$class]);
- return $node;
- }
- );
-
- return new ProcestFlowNodeListener($container, $this->createMock(LoggerInterface::class));
- }//end listener()
-
- /**
- * All fifteen actions land on the catalogue — both vocabularies.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testAllFifteenActionsAreRegistered(): void {
- $event = new RegisterFlowNodesEvent();
- $this->listener()->handle($event);
-
- $ids = array_map(
- static fn ($node): string => $node->getId(),
- $event->getRegisteredNodes()
- );
-
- $this->assertSame(
- array_values(self::EXPECTED_IDS),
- $ids
- );
-
- }//end testAllFifteenActionsAreRegistered()
-
- /**
- * An unrelated event is ignored rather than half-handled.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testUnrelatedEventIsIgnored(): void {
- $other = new class extends Event {
- };
-
- $this->listener()->handle($other);
-
- $this->addToAssertionCount(1);
-
- }//end testUnrelatedEventIsIgnored()
-
- /**
- * One unbuildable node does not cost the other fourteen their place.
- *
- * The list-based resolution introduced this branch: if a single node's
- * dependencies cannot be constructed, aborting would empty the whole
- * catalogue. A skipped node is visible — the editor simply does not offer
- * it — where a failed registration takes everything down with it.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testOneUnbuildableNodeDoesNotCostTheRest(): void {
- $event = new RegisterFlowNodesEvent();
- $this->listener(failing: [ProcestTxSetFieldNode::class])->handle($event);
-
- $ids = array_map(
- static fn ($node): string => $node->getId(),
- $event->getRegisteredNodes()
- );
-
- $this->assertCount(14, $ids);
- $this->assertNotContains('procest.setField', $ids);
- $this->assertContains('procest.sendEmail', $ids);
- $this->assertContains('procest.action.sendEmail', $ids);
-
- }//end testOneUnbuildableNodeDoesNotCostTheRest()
-
-}//end class
diff --git a/tests/Unit/Listener/DecisionConcludedListenerTest.php b/tests/Unit/Listener/DecisionConcludedListenerTest.php
index 558da695c..4260db533 100644
--- a/tests/Unit/Listener/DecisionConcludedListenerTest.php
+++ b/tests/Unit/Listener/DecisionConcludedListenerTest.php
@@ -25,7 +25,11 @@
namespace OCA\Dossiq\Tests\Unit\Listener;
-use OCA\Decidesk\Event\DecisionConcludedEvent;
+// The CURRENT namespace. The decision app renamed OCA\Decidesk -> OCA\Decidiq
+// with no alias, and the production resolver now prefers the current spelling —
+// so a test importing the OLD class asserts against an object the code no longer
+// builds. CrossAppEventNamesTest guards the ordering these follow.
+use OCA\Decidiq\Event\DecisionConcludedEvent;
use OCA\Dossiq\Listener\DecisionConcludedListener;
use OCA\Dossiq\Service\BesluitMaterialisationService;
use OCA\Dossiq\Service\Bezwaar\AdvisoryCommitteeService;
diff --git a/tests/Unit/Repair/BackfillAdviceRequestObjectionTest.php b/tests/Unit/Repair/BackfillAdviceRequestObjectionTest.php
new file mode 100644
index 000000000..cad6a20ff
--- /dev/null
+++ b/tests/Unit/Repair/BackfillAdviceRequestObjectionTest.php
@@ -0,0 +1,216 @@
+
+ * 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\BackfillAdviceRequestObjection;
+use OCA\Dossiq\Service\SettingsService;
+use OCP\IAppConfig;
+use OCP\Migration\IOutput;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Covers the backfill of `bacAdviceRequest.bezwaar`.
+ *
+ * The defect it repairs was invisible: advice requests were written with the
+ * objection id under the name of the SCHEMA it references
+ * (`objectionProceeding`) rather than the property the schema declares
+ * (`bezwaar`), so BezwaarDetail's advice-request widgets — which filter on
+ * `bezwaar` — rendered an empty list. An empty list is also what a bezwaar with
+ * no advice requests looks like, so nothing ever looked wrong.
+ */
+class BackfillAdviceRequestObjectionTest extends TestCase {
+ /**
+ * An ObjectService stand-in that records saves.
+ *
+ * @param array> $rows The stored rows.
+ *
+ * @return object The fake.
+ */
+ private function objectServiceFake(array $rows): object {
+ return new class($rows) {
+ /**
+ * @var array}>
+ */
+ public array $saves = [];
+
+ /**
+ * @param array> $rows The rows.
+ */
+ public function __construct(private array $rows) {
+ }
+
+ /**
+ * Run the callable straight through.
+ *
+ * @param callable $operation The operation.
+ *
+ * @return mixed The result.
+ */
+ public function runAsSystem(callable $operation) {
+ return $operation();
+ }
+
+ /**
+ * Return the configured rows.
+ *
+ * @param array $config The query config.
+ *
+ * @return array> The rows.
+ */
+ public function findAll(array $config): array {
+ return $this->rows;
+ }
+
+ /**
+ * Record the patch.
+ *
+ * @param array $object The patch.
+ * @param string $register The register.
+ * @param string $schema The schema.
+ * @param string $uuid The row uuid.
+ *
+ * @return array The saved object.
+ */
+ public function saveObject(array $object, string $register, string $schema, string $uuid): array {
+ $this->saves[] = ['uuid' => $uuid, 'object' => $object];
+
+ return $object;
+ }
+ };
+ }
+
+ /**
+ * Build the repair step around the given rows.
+ *
+ * @param object $objectService The ObjectService fake.
+ *
+ * @return BackfillAdviceRequestObjection The repair step.
+ */
+ private function step(object $objectService): BackfillAdviceRequestObjection {
+ $settings = $this->createMock(SettingsService::class);
+ $settings->method('getObjectService')->willReturn($objectService);
+
+ $appConfig = $this->createMock(IAppConfig::class);
+ $appConfig->method('getValueString')->willReturnCallback(
+ static fn (string $app, string $key, string $default = ''): string => ($key === 'register') ? '17' : '143'
+ );
+
+ return new BackfillAdviceRequestObjection(
+ $settings,
+ $appConfig,
+ $this->createMock(LoggerInterface::class),
+ );
+ }
+
+ /**
+ * A legacy row gets `bezwaar` written from `objectionProceeding`.
+ *
+ * @return void
+ */
+ public function testItCopiesTheLegacyKeyOntoTheDeclaredOne(): void {
+ $objectService = $this->objectServiceFake([
+ ['id' => 'req-1', 'objectionProceeding' => 'bezwaar-42', 'status' => 'assigned'],
+ ]);
+
+ $this->step($objectService)->run($this->createMock(IOutput::class));
+
+ $this->assertSame(
+ [['uuid' => 'req-1', 'object' => ['bezwaar' => 'bezwaar-42']]],
+ $objectService->saves
+ );
+ }
+
+ /**
+ * A row that already has `bezwaar` is left alone.
+ *
+ * @return void
+ */
+ public function testItSkipsARowThatIsAlreadyCorrect(): void {
+ $objectService = $this->objectServiceFake([
+ ['id' => 'req-1', 'bezwaar' => 'bezwaar-42', 'objectionProceeding' => 'bezwaar-42'],
+ ]);
+
+ $this->step($objectService)->run($this->createMock(IOutput::class));
+
+ $this->assertSame([], $objectService->saves, 'A correct row must not be rewritten.');
+ }
+
+ /**
+ * A row with NEITHER key is skipped, never written with an empty value.
+ *
+ * Writing `bezwaar => ''` would satisfy the required property while pointing
+ * at nothing — a row that looks repaired and still cannot be found by the
+ * filter that needed it.
+ *
+ * @return void
+ */
+ public function testItSkipsARowItCannotRepair(): void {
+ $objectService = $this->objectServiceFake([['id' => 'req-1', 'status' => 'assigned']]);
+
+ $this->step($objectService)->run($this->createMock(IOutput::class));
+
+ $this->assertSame([], $objectService->saves);
+ }
+
+ /**
+ * Running twice writes once — the second pass sees the repaired key.
+ *
+ * @return void
+ */
+ public function testItIsIdempotent(): void {
+ $objectService = $this->objectServiceFake([
+ ['id' => 'req-1', 'objectionProceeding' => 'bezwaar-42'],
+ ]);
+ $step = $this->step($objectService);
+
+ $step->run($this->createMock(IOutput::class));
+ $first = count($objectService->saves);
+
+ // Second pass over the REPAIRED shape, which is what a re-run reads.
+ $repaired = $this->objectServiceFake([
+ ['id' => 'req-1', 'bezwaar' => 'bezwaar-42', 'objectionProceeding' => 'bezwaar-42'],
+ ]);
+ $this->step($repaired)->run($this->createMock(IOutput::class));
+
+ $this->assertSame(1, $first);
+ $this->assertSame([], $repaired->saves);
+ }
+
+ /**
+ * With OpenRegister absent the step is a no-op rather than a fatal.
+ *
+ * An upgrade must not fail because a projection could not complete.
+ *
+ * @return void
+ */
+ public function testItIsANoOpWithoutOpenRegister(): void {
+ $settings = $this->createMock(SettingsService::class);
+ $settings->method('getObjectService')->willReturn(null);
+
+ $step = new BackfillAdviceRequestObjection(
+ $settings,
+ $this->createMock(IAppConfig::class),
+ $this->createMock(LoggerInterface::class),
+ );
+
+ $output = $this->createMock(IOutput::class);
+ $output->expects($this->once())->method('info');
+
+ $step->run($output);
+ }
+}
diff --git a/tests/Unit/Service/Actions/AutomaticActionFlowMigratorTest.php b/tests/Unit/Service/Actions/AutomaticActionFlowMigratorTest.php
new file mode 100644
index 000000000..b0e0638e2
--- /dev/null
+++ b/tests/Unit/Service/Actions/AutomaticActionFlowMigratorTest.php
@@ -0,0 +1,444 @@
+
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * @category Test
+ * @package OCA\Dossiq\Tests\Unit\Service\Actions
+ * @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\Service\Actions;
+
+use OCA\Dossiq\Service\Actions\AutomaticActionFlowMigrator;
+use OCA\Dossiq\Service\SettingsService;
+use OCA\OpenRegister\Service\Flow\FlowNodeRegistry;
+use OCA\OpenRegister\Service\Flow\IFlowNode;
+use OCP\IAppConfig;
+use OCP\IUser;
+use PHPUnit\Framework\TestCase;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Covers the automaticAction → flow projection.
+ *
+ * The migration turns configuration that has never executed into something that
+ * fires, so every branch that decides WHETHER to write is asserted here: an
+ * unimplemented action type must be skipped rather than wrapped in a flow that
+ * cannot run, and a re-run must update the flow it made last time instead of
+ * creating a second one.
+ */
+class AutomaticActionFlowMigratorTest extends TestCase {
+ /**
+ * A minimal stand-in for OpenRegister's FlowService.
+ *
+ * @return object The fake, exposing saves[] and a settable page.
+ */
+ private function flowServiceFake(): object {
+ return new class {
+ /**
+ * @var array, uuid: string|null}>
+ */
+ public array $saves = [];
+
+ /**
+ * @var array
+ */
+ public array $page = [];
+
+ /**
+ * Record a save and hand back a flow-like object.
+ *
+ * @param array $document The flow document.
+ * @param string|null $uuid The flow being updated.
+ *
+ * @return object The stored flow.
+ */
+ public function save(array $document, ?string $uuid = null): object {
+ $this->saves[] = ['document' => $document, 'uuid' => $uuid];
+
+ return new class($uuid ?? 'new-flow-uuid') {
+ /**
+ * @param string $uuid The uuid.
+ */
+ public function __construct(private string $uuid) {
+ }
+
+ /**
+ * @return string The uuid.
+ */
+ public function getUuid(): string {
+ return $this->uuid;
+ }
+ };
+ }
+
+ /**
+ * Return the first page of flows, then nothing.
+ *
+ * @param string|null $app The owning app.
+ * @param string|null $applicationSlug Unused.
+ * @param bool|null $enabled Unused.
+ * @param int $limit Page size.
+ * @param int $offset Page offset.
+ *
+ * @return array The page.
+ */
+ public function findAll(
+ ?string $app = null,
+ ?string $applicationSlug = null,
+ ?bool $enabled = null,
+ int $limit = 100,
+ int $offset = 0,
+ ): array {
+ if ($offset > 0) {
+ return [];
+ }
+
+ return $this->page;
+ }
+ };
+ }
+
+ /**
+ * A flow-like row carrying a provenance marker.
+ *
+ * @param string $notes The notes field.
+ * @param string $uuid The flow uuid.
+ *
+ * @return object The row.
+ */
+ private function flowRow(string $notes, string $uuid): object {
+ return new class($notes, $uuid) {
+ /**
+ * @param string $notes The notes.
+ * @param string $uuid The uuid.
+ */
+ public function __construct(private string $notes, private string $uuid) {
+ }
+
+ /**
+ * @return string The notes.
+ */
+ public function getNotes(): string {
+ return $this->notes;
+ }
+
+ /**
+ * @return string The uuid.
+ */
+ public function getUuid(): string {
+ return $this->uuid;
+ }
+ };
+ }
+
+ /**
+ * An ObjectService stand-in returning the given actions.
+ *
+ * @param array> $actions The stored actions.
+ *
+ * @return object The fake.
+ */
+ private function objectServiceFake(array $actions): object {
+ return new class($actions) {
+ /**
+ * @param array> $actions The actions.
+ */
+ public function __construct(private array $actions) {
+ }
+
+ /**
+ * Run the callable straight through.
+ *
+ * @param IUser $user The acting user.
+ * @param callable $operation The operation.
+ *
+ * @return mixed The result.
+ */
+ public function runAs(IUser $user, callable $operation) {
+ return $operation();
+ }
+
+ /**
+ * Return the configured actions.
+ *
+ * @param array $config The query config.
+ *
+ * @return array> The actions.
+ */
+ public function findAll(array $config): array {
+ return $this->actions;
+ }
+ };
+ }
+
+ /**
+ * Build the migrator with the given fakes.
+ *
+ * @param array> $actions Stored actions.
+ * @param object $flowService The flow-service fake.
+ * @param array $nodeIds Node ids the registry knows.
+ *
+ * @return AutomaticActionFlowMigrator The migrator.
+ */
+ private function migrator(array $actions, object $flowService, array $nodeIds): AutomaticActionFlowMigrator {
+ $registry = new FlowNodeRegistry();
+ foreach ($nodeIds as $id) {
+ $node = $this->createMock(IFlowNode::class);
+ $node->method('getId')->willReturn($id);
+ $registry->register($node);
+ }
+
+ $settings = $this->createMock(SettingsService::class);
+ $settings->method('getObjectService')->willReturn($this->objectServiceFake($actions));
+
+ $container = $this->createMock(ContainerInterface::class);
+ $container->method('get')->willReturnCallback(
+ static function (string $id) use ($flowService, $registry) {
+ if ($id === 'OCA\OpenRegister\Service\Flow\FlowService') {
+ return $flowService;
+ }
+
+ return $registry;
+ }
+ );
+
+ $appConfig = $this->createMock(IAppConfig::class);
+ $appConfig->method('getValueString')->willReturnCallback(
+ static fn (string $app, string $key, string $default = ''): string => ($key === 'register') ? '17' : '115'
+ );
+
+ return new AutomaticActionFlowMigrator(
+ $settings,
+ $container,
+ $appConfig,
+ $this->createMock(LoggerInterface::class),
+ );
+ }
+
+ /**
+ * One well-formed action becomes one enabled, runnable flow.
+ *
+ * @return void
+ */
+ public function testItCreatesAnEnabledFlowPerAction(): void {
+ $flowService = $this->flowServiceFake();
+ $migrator = $this->migrator(
+ [
+ [
+ 'tenantId' => 'tenant-a',
+ 'slug' => 'send-decision-email',
+ 'title' => 'Send decision email',
+ 'type' => 'sendEmail',
+ 'config' => '{"subject":"Uw besluit"}',
+ ],
+ ],
+ $flowService,
+ ['dossiq.action.sendEmail'],
+ );
+
+ $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false);
+
+ $this->assertSame(1, $summary['total']);
+ $this->assertSame(1, $summary['created']);
+ $this->assertCount(1, $flowService->saves);
+
+ $document = $flowService->saves[0]['document'];
+ $this->assertNull($flowService->saves[0]['uuid'], 'A first migration must CREATE, not update.');
+ $this->assertTrue($document['enabled']);
+ $this->assertSame('manual', $document['trigger']);
+ $this->assertSame(
+ ['openregister.trigger-manual', 'dossiq.action.sendEmail', 'openregister.end'],
+ array_column($document['nodes'], 'type'),
+ 'A flow OpenRegister will run needs an entry and an exit around the action.'
+ );
+ $this->assertSame(['subject' => 'Uw besluit'], $document['nodes'][1]['config']);
+ $this->assertSame('dossiq:automaticAction:tenant-a:send-decision-email', $document['notes']);
+ }
+
+ /**
+ * An action type no node implements is SKIPPED, never wrapped in a flow.
+ *
+ * Writing it would rebuild the exact defect this programme already fixed in
+ * the VTH catalog: a stored step naming a handler nothing answers to, which
+ * reports success and does nothing.
+ *
+ * @return void
+ */
+ public function testItSkipsAnActionTypeNoNodeImplements(): void {
+ $flowService = $this->flowServiceFake();
+ $migrator = $this->migrator(
+ [['tenantId' => 't', 'slug' => 'carrier-pigeon', 'title' => 'Pigeon', 'type' => 'sendCarrierPigeon']],
+ $flowService,
+ ['dossiq.action.sendEmail'],
+ );
+
+ $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false);
+
+ $this->assertSame(1, $summary['skipped']);
+ $this->assertSame(0, $summary['created']);
+ $this->assertSame([], $flowService->saves);
+ }
+
+ /**
+ * A re-run updates the flow it created, rather than making a second one.
+ *
+ * @return void
+ */
+ public function testItUpdatesTheFlowItAlreadyCreated(): void {
+ $flowService = $this->flowServiceFake();
+ $flowService->page = [
+ $this->flowRow('dossiq:automaticAction:tenant-a:send-decision-email', 'existing-uuid'),
+ ];
+
+ $migrator = $this->migrator(
+ [
+ [
+ 'tenantId' => 'tenant-a',
+ 'slug' => 'send-decision-email',
+ 'title' => 'Send decision email',
+ 'type' => 'sendEmail',
+ 'config' => '{}',
+ ],
+ ],
+ $flowService,
+ ['dossiq.action.sendEmail'],
+ );
+
+ $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false);
+
+ $this->assertSame(1, $summary['updated']);
+ $this->assertSame(0, $summary['created']);
+ $this->assertSame('existing-uuid', $flowService->saves[0]['uuid']);
+ }
+
+ /**
+ * An action missing tenantId or slug FAILS rather than sharing a marker.
+ *
+ * Defaulting the missing half would collapse several actions onto one
+ * marker, and each migration would overwrite the previous one's flow.
+ *
+ * @return void
+ */
+ public function testItFailsAnActionItCannotIdentify(): void {
+ $flowService = $this->flowServiceFake();
+ $migrator = $this->migrator(
+ [['slug' => 'no-tenant', 'title' => 'Orphan', 'type' => 'sendEmail']],
+ $flowService,
+ ['dossiq.action.sendEmail'],
+ );
+
+ $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false);
+
+ $this->assertSame(1, $summary['failed']);
+ $this->assertSame([], $flowService->saves);
+ }
+
+ /**
+ * A dry run reports the same outcomes and writes nothing.
+ *
+ * @return void
+ */
+ public function testADryRunWritesNothing(): void {
+ $flowService = $this->flowServiceFake();
+ $migrator = $this->migrator(
+ [['tenantId' => 't', 'slug' => 's', 'title' => 'T', 'type' => 'sendEmail', 'config' => '{}']],
+ $flowService,
+ ['dossiq.action.sendEmail'],
+ );
+
+ $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: true);
+
+ $this->assertSame(1, $summary['created']);
+ $this->assertSame([], $flowService->saves, 'A dry run that writes is not a dry run.');
+ }
+
+ /**
+ * A save that throws is reported as failed and does not abort the rest.
+ *
+ * @return void
+ */
+ public function testOneFailingActionDoesNotAbortTheRest(): void {
+ $flowService = new class($this->flowServiceFake()) {
+ /**
+ * @var array, uuid: string|null}>
+ */
+ public array $saves = [];
+
+ /**
+ * @param object $inner Unused; keeps the shape symmetric.
+ */
+ public function __construct(private object $inner) {
+ }
+
+ /**
+ * Throw for the first action, succeed for the second.
+ *
+ * @param array $document The flow document.
+ * @param string|null $uuid The flow being updated.
+ *
+ * @return object The stored flow.
+ */
+ public function save(array $document, ?string $uuid = null): object {
+ if ($document['name'] === 'Boom') {
+ throw new \RuntimeException('storage exploded');
+ }
+
+ $this->saves[] = ['document' => $document, 'uuid' => $uuid];
+
+ return new class {
+ /**
+ * @return string The uuid.
+ */
+ public function getUuid(): string {
+ return 'ok-uuid';
+ }
+ };
+ }
+
+ /**
+ * No pre-existing flows.
+ *
+ * @param string|null $app The owning app.
+ * @param string|null $applicationSlug Unused.
+ * @param bool|null $enabled Unused.
+ * @param int $limit Page size.
+ * @param int $offset Page offset.
+ *
+ * @return array The page.
+ */
+ public function findAll(
+ ?string $app = null,
+ ?string $applicationSlug = null,
+ ?bool $enabled = null,
+ int $limit = 100,
+ int $offset = 0,
+ ): array {
+ return [];
+ }
+ };
+
+ $migrator = $this->migrator(
+ [
+ ['tenantId' => 't', 'slug' => 'boom', 'title' => 'Boom', 'type' => 'sendEmail', 'config' => '{}'],
+ ['tenantId' => 't', 'slug' => 'fine', 'title' => 'Fine', 'type' => 'sendEmail', 'config' => '{}'],
+ ],
+ $flowService,
+ ['dossiq.action.sendEmail'],
+ );
+
+ $summary = $migrator->migrate(user: $this->createMock(IUser::class), dryRun: false);
+
+ $this->assertSame(1, $summary['failed']);
+ $this->assertSame(1, $summary['created']);
+ $this->assertCount(1, $flowService->saves);
+ }
+}
diff --git a/tests/Unit/Service/AgendaServiceTest.php b/tests/Unit/Service/AgendaServiceTest.php
deleted file mode 100644
index 532245a63..000000000
--- a/tests/Unit/Service/AgendaServiceTest.php
+++ /dev/null
@@ -1,216 +0,0 @@
-
- * @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.
- *
- * @link https://conduction.nl
- */
-
-declare(strict_types=1);
-
-namespace OCA\Dossiq\Tests\Unit\Service;
-
-use OCA\Dossiq\Service\AgendaService;
-use OCA\Dossiq\Service\SettingsService;
-use PHPUnit\Framework\TestCase;
-use Psr\Log\LoggerInterface;
-
-/**
- * ObjectService stub matching the named-argument signatures used by AgendaService.
- */
-interface AgendaObjectServiceStub {
- /**
- * Find a single object by id.
- *
- * @param string $id The object id.
- * @param string $register The register slug.
- * @param string $schema The schema id.
- *
- * @return mixed
- */
- public function find(string $id, string $register, string $schema): mixed;
-
- /**
- * Save or update an object.
- *
- * @param array $object The object payload.
- * @param string $register The register slug.
- * @param string $schema The schema id.
- *
- * @return array
- */
- public function saveObject(array $object, string $register, string $schema): array;
-}//end interface
-
-/**
- * Unit tests for AgendaService.
- *
- * @covers \OCA\Dossiq\Service\AgendaService
- */
-class AgendaServiceTest extends TestCase {
-
- /**
- * The mocked settings service.
- *
- * @var SettingsService|\PHPUnit\Framework\MockObject\MockObject
- */
- private SettingsService $settingsService;
-
- /**
- * The service under test.
- *
- * @var AgendaService
- */
- private AgendaService $service;
-
- /**
- * Set up fixtures.
- *
- * @return void
- */
- protected function setUp(): void {
- $this->settingsService = $this->createMock(originalClassName: SettingsService::class);
- $logger = $this->createMock(originalClassName: LoggerInterface::class);
-
- $this->settingsService->method('getConfigValue')->willReturnCallback(
- static function (string $key): string {
- if ($key === 'register') {
- return 'reg';
- }
-
- return 'schema-' . $key;
- }
- );
-
- $this->service = new AgendaService(settingsService: $this->settingsService, logger: $logger);
- }//end setUp()
-
- /**
- * AddToAgenda appends an item with a generated itemId + createdAt and persists it.
- *
- * @return void
- */
- public function testAddToAgendaAppendsAndPersists(): void {
- $objectService = $this->createMock(originalClassName: AgendaObjectServiceStub::class);
- $objectService->method('find')->willReturn(['id' => 'c1', 'agendaItems' => []]);
-
- $saved = null;
- $objectService->method('saveObject')->willReturnCallback(
- static function (array $object) use (&$saved): array {
- $saved = $object;
- return $object;
- }
- );
-
- $this->settingsService->method('getObjectService')->willReturn($objectService);
-
- $result = $this->service->addToAgenda('c1', ['meetingDate' => '2026-07-01', 'discussionStatus' => 'planned']);
-
- $this->assertSame(expected: 'c1', actual: $result['caseId']);
- $this->assertCount(expectedCount: 1, haystack: $result['agendaItems']);
- $this->assertArrayHasKey(key: 'itemId', array: $result['agendaItems'][0]);
- $this->assertArrayHasKey(key: 'createdAt', array: $result['agendaItems'][0]);
- $this->assertSame(expected: '2026-07-01', actual: $result['agendaItems'][0]['meetingDate']);
- $this->assertNotNull(actual: $saved);
- $this->assertSame(expected: $result['agendaItems'], actual: $saved['agendaItems']);
- }//end testAddToAgendaAppendsAndPersists()
-
- /**
- * AddToAgenda decodes a JSON-string agendaItems field (dossiq string-encoding contract).
- *
- * @return void
- */
- public function testAddToAgendaDecodesJsonStringItems(): void {
- $objectService = $this->createMock(originalClassName: AgendaObjectServiceStub::class);
- $existing = json_encode([['itemId' => 'agenda_old', 'meetingDate' => '2026-06-01']]);
- $objectService->method('find')->willReturn(['id' => 'c1', 'agendaItems' => $existing]);
- $objectService->method('saveObject')->willReturnArgument(0);
-
- $this->settingsService->method('getObjectService')->willReturn($objectService);
-
- $result = $this->service->addToAgenda('c1', ['meetingDate' => '2026-07-01']);
-
- $this->assertCount(expectedCount: 2, haystack: $result['agendaItems']);
- $this->assertSame(expected: 'agenda_old', actual: $result['agendaItems'][0]['itemId']);
- }//end testAddToAgendaDecodesJsonStringItems()
-
- /**
- * UpdateAgendaItem merges a patch onto an existing item matched by itemId.
- *
- * @return void
- */
- public function testUpdateAgendaItemMergesByItemId(): void {
- $objectService = $this->createMock(originalClassName: AgendaObjectServiceStub::class);
- $objectService->method('find')->willReturn(
- [
- 'id' => 'c1',
- 'agendaItems' => [
- ['itemId' => 'a1', 'meetingDate' => '2026-06-01', 'discussionStatus' => 'planned'],
- ],
- ]
- );
- $objectService->method('saveObject')->willReturnArgument(0);
-
- $this->settingsService->method('getObjectService')->willReturn($objectService);
-
- $result = $this->service->updateAgendaItem('c1', ['itemId' => 'a1', 'discussionStatus' => 'behandeld']);
-
- $this->assertCount(expectedCount: 1, haystack: $result['agendaItems']);
- $this->assertSame(expected: 'behandeld', actual: $result['agendaItems'][0]['discussionStatus']);
- $this->assertSame(expected: '2026-06-01', actual: $result['agendaItems'][0]['meetingDate']);
- }//end testUpdateAgendaItemMergesByItemId()
-
- /**
- * UpdateAgendaItem requires an itemId in the patch.
- *
- * @return void
- */
- public function testUpdateAgendaItemRequiresItemId(): void {
- $objectService = $this->createMock(originalClassName: AgendaObjectServiceStub::class);
- $objectService->method('find')->willReturn(['id' => 'c1', 'agendaItems' => []]);
- $this->settingsService->method('getObjectService')->willReturn($objectService);
-
- $this->expectException(exception: \InvalidArgumentException::class);
- $this->service->updateAgendaItem('c1', ['discussionStatus' => 'behandeld']);
- }//end testUpdateAgendaItemRequiresItemId()
-
- /**
- * UpdateAgendaItem throws when the itemId is not present on the case.
- *
- * @return void
- */
- public function testUpdateAgendaItemThrowsWhenNotFound(): void {
- $objectService = $this->createMock(originalClassName: AgendaObjectServiceStub::class);
- $objectService->method('find')->willReturn(['id' => 'c1', 'agendaItems' => []]);
- $this->settingsService->method('getObjectService')->willReturn($objectService);
-
- $this->expectException(exception: \RuntimeException::class);
- $this->service->updateAgendaItem('c1', ['itemId' => 'missing']);
- }//end testUpdateAgendaItemThrowsWhenNotFound()
-
- /**
- * AddToAgenda throws when OpenRegister is unavailable.
- *
- * @return void
- */
- public function testAddToAgendaThrowsWhenObjectServiceMissing(): void {
- $this->settingsService->method('getObjectService')->willReturn(null);
-
- $this->expectException(exception: \RuntimeException::class);
- $this->service->addToAgenda('c1', ['meetingDate' => '2026-07-01']);
- }//end testAddToAgendaThrowsWhenObjectServiceMissing()
-}//end class
diff --git a/tests/Unit/Service/ParaferingApprovalBridgeTest.php b/tests/Unit/Service/ParaferingApprovalBridgeTest.php
deleted file mode 100644
index 1c9763729..000000000
--- a/tests/Unit/Service/ParaferingApprovalBridgeTest.php
+++ /dev/null
@@ -1,381 +0,0 @@
- OpenRegister approval-workflow bridge maps route
- * steps to OpenRegister ApprovalChain steps, routes approve/reject through
- * OpenRegister's ApprovalService against the pending step, encodes app-specific
- * metadata into the comment field, and degrades gracefully when OpenRegister's
- * approval-workflow backend is unavailable.
- *
- * @category Tests
- * @package OCA\Dossiq\Tests\Unit\Service
- *
- * @author Conduction Development Team
- * @copyright 2026 Conduction B.V.
- * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
- *
- * @version GIT:
- *
- * @link https://conduction.nl
- */
-
-declare(strict_types=1);
-
-namespace OCA\Dossiq\Tests\Unit\Service;
-
-use OCA\Dossiq\Service\ParaferingApprovalBridge;
-use OCA\Dossiq\Service\SettingsService;
-use PHPUnit\Framework\TestCase;
-use Psr\Log\LoggerInterface;
-use RuntimeException;
-
-/**
- * Unit tests for ParaferingApprovalBridge.
- *
- * @covers \OCA\Dossiq\Service\ParaferingApprovalBridge
- */
-class ParaferingApprovalBridgeTest extends TestCase {
- /**
- * Mocked dossiq settings/OpenRegister bridge.
- *
- * @var SettingsService|\PHPUnit\Framework\MockObject\MockObject
- */
- private SettingsService $settings;
-
- /**
- * Mocked logger.
- *
- * @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject
- */
- private LoggerInterface $logger;
-
- /**
- * Set up mocks.
- *
- * @return void
- */
- protected function setUp(): void {
- $this->settings = $this->createMock(SettingsService::class);
- $this->logger = $this->createMock(LoggerInterface::class);
- }//end setUp()
-
- /**
- * Build a fake OpenRegister ApprovalChain entity with a UUID.
- *
- * @param string $uuid The chain UUID to report.
- *
- * @return object The fake chain.
- */
- private function fakeChain(string $uuid): object {
- return new class($uuid) {
- /**
- * @param string $uuid Chain UUID.
- */
- public function __construct(
- private string $uuid,
- ) {
- }
-
- /**
- * @return string The chain UUID.
- */
- public function getUuid(): string {
- return $this->uuid;
- }
- };
- }//end fakeChain()
-
- /**
- * Build a fake OpenRegister ApprovalStep entity.
- *
- * @param int $id Step id.
- * @param string $status Step status.
- *
- * @return object The fake step.
- */
- private function fakeStep(int $id, string $status): object {
- return new class($id, $status) {
- /**
- * @param int $id Step id.
- * @param string $status Step status.
- */
- public function __construct(
- private int $id,
- private string $status,
- ) {
- }
-
- /**
- * @return int Step id.
- */
- public function getId(): int {
- return $this->id;
- }
-
- /**
- * @return string Step status.
- */
- public function getStatus(): string {
- return $this->status;
- }
- };
- }//end fakeStep()
-
- /**
- * When OpenRegister is unavailable the bridge reports not-available and
- * chain creation returns null (legacy path governs).
- *
- * @return void
- */
- public function testIsAvailableFalseWhenOpenRegisterMissing(): void {
- $this->settings->method('getApprovalService')->willReturn(null);
- $this->settings->method('getOpenRegisterClass')->willReturn(null);
-
- $bridge = new ParaferingApprovalBridge($this->settings, $this->logger);
-
- $this->assertFalse($bridge->isAvailable());
- $this->assertNull(
- $bridge->initializeChainForVoorstel('voorstel-1', 'Route', [['order' => 1, 'role' => 'team']])
- );
- }//end testIsAvailableFalseWhenOpenRegisterMissing()
-
- /**
- * Chain creation maps route steps to OpenRegister steps, persists via the
- * chain mapper, initialises the chain, and returns the chain UUID.
- *
- * @return void
- */
- public function testInitializeChainCreatesOrChainAndReturnsUuid(): void {
- $captured = null;
-
- $chainMapper = new class($this->fakeChain('chain-uuid-9')) {
- /** @var object */
- public static $captured = null;
-
- /**
- * @param object $chain Chain to return.
- */
- public function __construct(
- private object $chain,
- ) {
- }
-
- /**
- * @param array $data Chain data.
- *
- * @return object The created chain.
- */
- public function createFromArray(array $data): object {
- self::$captured = $data;
- return $this->chain;
- }
- };
-
- $approvalService = new class {
- /** @var array */
- public static $init = [];
-
- /**
- * @param object $chain The chain.
- * @param string $objectUuid The object UUID.
- *
- * @return array Created steps.
- */
- public function initializeChain(object $chain, string $objectUuid): array {
- self::$init = ['chain' => $chain, 'objectUuid' => $objectUuid];
- return [];
- }
- };
-
- $this->settings->method('getApprovalService')->willReturn($approvalService);
- $this->settings->method('getOpenRegisterClass')->willReturnCallback(
- static function (string $class) use ($chainMapper) {
- if (str_contains($class, 'ApprovalChainMapper') === true) {
- return $chainMapper;
- }
-
- // Step mapper presence is required for isAvailable().
- return new \stdClass();
- }
- );
-
- $bridge = new ParaferingApprovalBridge($this->settings, $this->logger);
-
- $steps = [
- ['order' => 1, 'role' => 'teamleider', 'type' => 'parafering'],
- ['order' => 2, 'role' => 'directeur', 'type' => 'accordering'],
- ['order' => 3, 'role' => 'skipme', 'skipped' => true],
- ];
-
- $uuid = $bridge->initializeChainForVoorstel('voorstel-abc', 'Collegeadvies', $steps);
-
- $this->assertSame('chain-uuid-9', $uuid);
- $this->assertSame('voorstel-abc', $approvalService::$init['objectUuid']);
-
- $mapped = $chainMapper::$captured;
- $this->assertSame('Collegeadvies', $mapped['name']);
- // Skipped step is filtered out; two steps remain.
- $this->assertCount(2, $mapped['steps']);
- $this->assertSame('teamleider', $mapped['steps'][0]['role']);
- $this->assertSame('directeur', $mapped['steps'][1]['role']);
- }//end testInitializeChainCreatesOrChainAndReturnsUuid()
-
- /**
- * Approving the current step resolves the pending step and calls
- * OpenRegister approveStep with a JSON metadata-in-comment payload.
- *
- * @return void
- */
- public function testApproveCurrentStepDelegatesWithMetaComment(): void {
- $approvalService = new class {
- /** @var array */
- public static $call = [];
-
- /**
- * @param int $stepId Step id.
- * @param string $userId User id.
- * @param string $comment Comment.
- *
- * @return array Result.
- */
- public function approveStep(int $stepId, string $userId, string $comment): array {
- self::$call = ['stepId' => $stepId, 'userId' => $userId, 'comment' => $comment];
- return ['step' => $stepId];
- }
- };
-
- $stepMapper = new class($this->fakeStep(42, 'pending')) {
- /**
- * @param object $step Step to return.
- */
- public function __construct(
- private object $step,
- ) {
- }
-
- /**
- * @param string $objectUuid Object UUID.
- *
- * @return array Steps.
- */
- public function findByObjectUuid(string $objectUuid): array {
- return [$this->step];
- }
- };
-
- $this->settings->method('getApprovalService')->willReturn($approvalService);
- $this->settings->method('getOpenRegisterClass')->willReturn($stepMapper);
-
- $bridge = new ParaferingApprovalBridge($this->settings, $this->logger);
-
- $result = $bridge->approveCurrentStep(
- 'voorstel-abc',
- 'alice',
- 'Akkoord',
- ['action' => 'parafered', 'actorType' => 'delegate', 'onBehalfOf' => 'bob', 'mandate' => 'M-1']
- );
-
- $this->assertIsArray($result);
- $this->assertSame(42, $approvalService::$call['stepId']);
- $this->assertSame('alice', $approvalService::$call['userId']);
-
- $decoded = json_decode($approvalService::$call['comment'], true);
- $this->assertSame('Akkoord', $decoded['text']);
- $this->assertSame('delegate', $decoded['_meta']['actorType']);
- $this->assertSame('bob', $decoded['_meta']['onBehalfOf']);
- $this->assertSame('M-1', $decoded['_meta']['mandate']);
- $this->assertArrayNotHasKey('advice', $decoded['_meta']);
- }//end testApproveCurrentStepDelegatesWithMetaComment()
-
- /**
- * Rejecting a step with no pending step raises a RuntimeException.
- *
- * @return void
- */
- public function testRejectThrowsWhenNoPendingStep(): void {
- $approvalService = new class {
- /**
- * @param int $stepId Step id.
- * @param string $userId User id.
- * @param string $comment Comment.
- *
- * @return array Result.
- */
- public function rejectStep(int $stepId, string $userId, string $comment): array {
- return [];
- }
- };
-
- $stepMapper = new class {
- /**
- * @param string $objectUuid Object UUID.
- *
- * @return array Steps (none pending).
- */
- public function findByObjectUuid(string $objectUuid): array {
- return [];
- }
- };
-
- $this->settings->method('getApprovalService')->willReturn($approvalService);
- $this->settings->method('getOpenRegisterClass')->willReturn($stepMapper);
-
- $bridge = new ParaferingApprovalBridge($this->settings, $this->logger);
-
- $this->expectException(RuntimeException::class);
- $bridge->rejectCurrentStep('voorstel-abc', 'alice', 'Ontbreekt', ['action' => 'returned']);
- }//end testRejectThrowsWhenNoPendingStep()
-
- /**
- * A plain comment with no meta is passed through unchanged (no JSON wrap).
- *
- * @return void
- */
- public function testPlainCommentPassedThroughWhenNoMeta(): void {
- $approvalService = new class {
- /** @var string */
- public static $comment = '';
-
- /**
- * @param int $stepId Step id.
- * @param string $userId User id.
- * @param string $comment Comment.
- *
- * @return array Result.
- */
- public function approveStep(int $stepId, string $userId, string $comment): array {
- self::$comment = $comment;
- return [];
- }
- };
-
- $stepMapper = new class($this->fakeStep(7, 'pending')) {
- /**
- * @param object $step Step.
- */
- public function __construct(
- private object $step,
- ) {
- }
-
- /**
- * @param string $objectUuid Object UUID.
- *
- * @return array Steps.
- */
- public function findByObjectUuid(string $objectUuid): array {
- return [$this->step];
- }
- };
-
- $this->settings->method('getApprovalService')->willReturn($approvalService);
- $this->settings->method('getOpenRegisterClass')->willReturn($stepMapper);
-
- $bridge = new ParaferingApprovalBridge($this->settings, $this->logger);
- $bridge->approveCurrentStep('voorstel-abc', 'alice', 'Akkoord', []);
-
- $this->assertSame('Akkoord', $approvalService::$comment);
- }//end testPlainCommentPassedThroughWhenNoMeta()
-}//end class
diff --git a/tests/Unit/Service/RemainingDecisionDelegationTest.php b/tests/Unit/Service/RemainingDecisionDelegationTest.php
index dfed0dcf3..d4f87f2ff 100644
--- a/tests/Unit/Service/RemainingDecisionDelegationTest.php
+++ b/tests/Unit/Service/RemainingDecisionDelegationTest.php
@@ -22,7 +22,11 @@
namespace OCA\Dossiq\Tests\Unit\Service;
-use OCA\Decidesk\Event\DecisionRequestedEvent;
+// The CURRENT namespace. The decision app renamed OCA\Decidesk -> OCA\Decidiq
+// with no alias, and the production resolver now prefers the current spelling —
+// so a test importing the OLD class asserts against an object the code no longer
+// builds. CrossAppEventNamesTest guards the ordering these follow.
+use OCA\Decidiq\Event\DecisionRequestedEvent;
use OCA\Dossiq\Service\AdviceDelegationService;
use OCA\Dossiq\Service\BezwaarDecisionDelegationService;
use OCA\Dossiq\Service\ContractDecisionDelegationService;
diff --git a/tests/Unit/Service/Transitions/SideEffectDispatcherTest.php b/tests/Unit/Service/Transitions/SideEffectDispatcherTest.php
index b56fe182c..912f0714b 100644
--- a/tests/Unit/Service/Transitions/SideEffectDispatcherTest.php
+++ b/tests/Unit/Service/Transitions/SideEffectDispatcherTest.php
@@ -16,12 +16,12 @@
namespace OCA\Dossiq\Tests\Unit\Service\Transitions;
+use OCA\OpenRegister\Service\Flow\FlowNodeRegistry;
+use OCA\OpenRegister\Service\Flow\IFlowNode;
use OCA\Dossiq\Service\Transitions\ActionHandlerInterface;
use OCA\Dossiq\Service\Transitions\ActionHandlerRegistry;
use OCA\Dossiq\Service\Transitions\ActionResult;
use OCA\Dossiq\Service\Transitions\SideEffectDispatcher;
-use OCA\OpenRegister\Service\Flow\FlowNodeRegistry;
-use OCA\OpenRegister\Service\Flow\IFlowNode;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
@@ -38,180 +38,190 @@
*/
class SideEffectDispatcherTest extends TestCase {
- /**
- * A node that records nothing and either succeeds or throws.
- *
- * @param string $id The node id.
- * @param \Throwable|null $throws Optional failure to raise.
- *
- * @return IFlowNode The node.
- */
- private function node(string $id, ?\Throwable $throws = null): IFlowNode {
- $node = $this->createMock(IFlowNode::class);
- $node->method('getId')->willReturn($id);
- if ($throws !== null) {
- $node->method('execute')->willThrowException($throws);
- } else {
- $node->method('execute')->willReturnArgument(0);
- }
-
- return $node;
- }//end node()
-
- /**
- * Build the dispatcher over an optional node catalogue.
- *
- * @param FlowNodeRegistry|null $nodes The catalogue, or null for the fallback path.
- * @param ActionHandlerRegistry|null $legacy The local registry.
- *
- * @return SideEffectDispatcher The dispatcher.
- */
- private function dispatcher(?FlowNodeRegistry $nodes, ?ActionHandlerRegistry $legacy = null): SideEffectDispatcher {
- $container = $this->createMock(ContainerInterface::class);
- if ($nodes !== null) {
- $container->method('get')->willReturn($nodes);
- } else {
- $container->method('get')->willThrowException(new RuntimeException('absent'));
- }
-
- return new SideEffectDispatcher(
- $legacy ?? $this->createMock(ActionHandlerRegistry::class),
- $container,
- $this->createMock(LoggerInterface::class)
- );
-
- }//end dispatcher()
-
- /**
- * With OpenRegister present, a transition action runs the SHARED node.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testActionsRunThroughTheSharedNode(): void {
- $registry = new FlowNodeRegistry();
- $registry->register($this->node('procest.sendEmail'));
-
- $results = $this->dispatcher($registry)->dispatch(
- [['type' => 'sendEmail']],
- ['id' => 'case-1'],
- ['transition' => 'submitted']
- );
-
- $this->assertSame([['type' => 'sendEmail', 'ok' => true]], $results);
-
- }//end testActionsRunThroughTheSharedNode()
-
- /**
- * The dispatcher resolves the LIVE id space, not the catalogue's.
- *
- * Both action systems ship a sendEmail. Resolving `procest.action.sendEmail`
- * here would run the configured-action handler for a transition — a
- * different class with different config keys.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testItResolvesTheLiveIdSpace(): void {
- $registry = new FlowNodeRegistry();
- $registry->register($this->node('procest.action.sendEmail'));
-
- $results = $this->dispatcher($registry)->dispatch([['type' => 'sendEmail']], [], []);
-
- $this->assertFalse($results[0]['ok']);
- $this->assertSame('unknown_action_type', $results[0]['error']);
-
- }//end testItResolvesTheLiveIdSpace()
-
- /**
- * A node that throws becomes a failed row — it does NOT abort the loop.
- *
- * A node signals failure by throwing, because the flow engine's onError
- * policy only sees what propagates. This dispatcher's contract is the
- * opposite and predates it: a failed action must not stop the remaining
- * ones or roll back the status change.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testAFailedActionDoesNotAbortTheRest(): void {
- $registry = new FlowNodeRegistry();
- $registry->register($this->node('procest.sendEmail', new RuntimeException('smtp down')));
- $registry->register($this->node('procest.createTask'));
-
- $results = $this->dispatcher($registry)->dispatch(
- [['type' => 'sendEmail'], ['type' => 'createTask']],
- [],
- []
- );
-
- $this->assertCount(2, $results);
- $this->assertFalse($results[0]['ok']);
- $this->assertSame('smtp down', $results[0]['error']);
- $this->assertTrue($results[1]['ok']);
-
- }//end testAFailedActionDoesNotAbortTheRest()
-
- /**
- * An action type nothing provides is reported, not silently dropped.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testUnknownActionTypeIsReported(): void {
- $results = $this->dispatcher(new FlowNodeRegistry())->dispatch(
- [['type' => 'doesNotExist']],
- [],
- []
- );
-
- $this->assertSame(
- [['type' => 'doesNotExist', 'ok' => false, 'error' => 'unknown_action_type']],
- $results
- );
-
- }//end testUnknownActionTypeIsReported()
-
- /**
- * Without OpenRegister the local handlers still fire.
- *
- * A transition must never silently skip its side effects because a
- * neighbouring app is not installed.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testFallsBackToTheLocalHandlersWithoutOpenRegister(): void {
- $handler = $this->createMock(ActionHandlerInterface::class);
- $handler->expects($this->once())->method('handle')->willReturn(new ActionResult(true));
-
- $legacy = $this->createMock(ActionHandlerRegistry::class);
- $legacy->method('getHandler')->willReturn($handler);
-
- $results = $this->dispatcher(null, $legacy)->dispatch([['type' => 'sendEmail']], [], []);
-
- $this->assertSame([['type' => 'sendEmail', 'ok' => true]], $results);
-
- }//end testFallsBackToTheLocalHandlersWithoutOpenRegister()
-
- /**
- * An action with no type is skipped rather than dispatched blind.
- *
- * @return void
- *
- * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
- */
- public function testTypelessActionIsSkipped(): void {
- $this->assertSame(
- [],
- $this->dispatcher(new FlowNodeRegistry())->dispatch([['config' => 1]], [], [])
- );
-
- }//end testTypelessActionIsSkipped()
+
+ /**
+ * A node that records nothing and either succeeds or throws.
+ *
+ * @param string $id The node id.
+ * @param \Throwable|null $throws Optional failure to raise.
+ *
+ * @return IFlowNode The node.
+ */
+ private function node(string $id, ?\Throwable $throws=null): IFlowNode {
+ $node = $this->createMock(IFlowNode::class);
+ $node->method('getId')->willReturn($id);
+ if ($throws !== null) {
+ $node->method('execute')->willThrowException($throws);
+ } else {
+ $node->method('execute')->willReturnArgument(0);
+ }
+
+ return $node;
+
+ }//end node()
+
+
+ /**
+ * Build the dispatcher over an optional node catalogue.
+ *
+ * @param FlowNodeRegistry|null $nodes The catalogue, or null for the fallback path.
+ * @param ActionHandlerRegistry|null $legacy The local registry.
+ *
+ * @return SideEffectDispatcher The dispatcher.
+ */
+ private function dispatcher(?FlowNodeRegistry $nodes, ?ActionHandlerRegistry $legacy=null): SideEffectDispatcher {
+ $container = $this->createMock(ContainerInterface::class);
+ if ($nodes !== null) {
+ $container->method('get')->willReturn($nodes);
+ } else {
+ $container->method('get')->willThrowException(new RuntimeException('absent'));
+ }
+
+ return new SideEffectDispatcher(
+ $legacy ?? $this->createMock(ActionHandlerRegistry::class),
+ $container,
+ $this->createMock(LoggerInterface::class)
+ );
+
+ }//end dispatcher()
+
+
+ /**
+ * With OpenRegister present, a transition action runs the SHARED node.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testActionsRunThroughTheSharedNode(): void {
+ $registry = new FlowNodeRegistry();
+ $registry->register($this->node('dossiq.sendEmail'));
+
+ $results = $this->dispatcher($registry)->dispatch(
+ [['type' => 'sendEmail']],
+ ['id' => 'case-1'],
+ ['transition' => 'submitted']
+ );
+
+ $this->assertSame([['type' => 'sendEmail', 'ok' => true]], $results);
+
+ }//end testActionsRunThroughTheSharedNode()
+
+
+ /**
+ * The dispatcher resolves the LIVE id space, not the catalogue's.
+ *
+ * Both action systems ship a sendEmail. Resolving `dossiq.action.sendEmail`
+ * here would run the configured-action handler for a transition — a
+ * different class with different config keys.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testItResolvesTheLiveIdSpace(): void {
+ $registry = new FlowNodeRegistry();
+ $registry->register($this->node('dossiq.action.sendEmail'));
+
+ $results = $this->dispatcher($registry)->dispatch([['type' => 'sendEmail']], [], []);
+
+ $this->assertFalse($results[0]['ok']);
+ $this->assertSame('unknown_action_type', $results[0]['error']);
+
+ }//end testItResolvesTheLiveIdSpace()
+
+
+ /**
+ * A node that throws becomes a failed row — it does NOT abort the loop.
+ *
+ * A node signals failure by throwing, because the flow engine's onError
+ * policy only sees what propagates. This dispatcher's contract is the
+ * opposite and predates it: a failed action must not stop the remaining
+ * ones or roll back the status change.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testAFailedActionDoesNotAbortTheRest(): void {
+ $registry = new FlowNodeRegistry();
+ $registry->register($this->node('dossiq.sendEmail', new RuntimeException('smtp down')));
+ $registry->register($this->node('dossiq.createTask'));
+
+ $results = $this->dispatcher($registry)->dispatch(
+ [['type' => 'sendEmail'], ['type' => 'createTask']],
+ [],
+ []
+ );
+
+ $this->assertCount(2, $results);
+ $this->assertFalse($results[0]['ok']);
+ $this->assertSame('smtp down', $results[0]['error']);
+ $this->assertTrue($results[1]['ok']);
+
+ }//end testAFailedActionDoesNotAbortTheRest()
+
+
+ /**
+ * An action type nothing provides is reported, not silently dropped.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testUnknownActionTypeIsReported(): void {
+ $results = $this->dispatcher(new FlowNodeRegistry())->dispatch(
+ [['type' => 'doesNotExist']],
+ [],
+ []
+ );
+
+ $this->assertSame(
+ [['type' => 'doesNotExist', 'ok' => false, 'error' => 'unknown_action_type']],
+ $results
+ );
+
+ }//end testUnknownActionTypeIsReported()
+
+
+ /**
+ * Without OpenRegister the local handlers still fire.
+ *
+ * A transition must never silently skip its side effects because a
+ * neighbouring app is not installed.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testFallsBackToTheLocalHandlersWithoutOpenRegister(): void {
+ $handler = $this->createMock(ActionHandlerInterface::class);
+ $handler->expects($this->once())->method('handle')->willReturn(new ActionResult(true));
+
+ $legacy = $this->createMock(ActionHandlerRegistry::class);
+ $legacy->method('getHandler')->willReturn($handler);
+
+ $results = $this->dispatcher(null, $legacy)->dispatch([['type' => 'sendEmail']], [], []);
+
+ $this->assertSame([['type' => 'sendEmail', 'ok' => true]], $results);
+
+ }//end testFallsBackToTheLocalHandlersWithoutOpenRegister()
+
+
+ /**
+ * An action with no type is skipped rather than dispatched blind.
+ *
+ * @return void
+ *
+ * @spec openspec/changes/page-topology-cleanup/specs/automatic-actions-surface/spec.md
+ */
+ public function testTypelessActionIsSkipped(): void {
+ $this->assertSame(
+ [],
+ $this->dispatcher(new FlowNodeRegistry())->dispatch([['config' => 1]], [], [])
+ );
+
+ }//end testTypelessActionIsSkipped()
+
}//end class
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 94753182b..2a0a55d3b 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -264,16 +264,21 @@ static function (string $class): bool {
include_once __DIR__ . '/Stubs/Mcp/IMcpToolProvider.php';
}
-// Decidesk decision-event stubs — loaded when the decidesk app is absent so the
-// dossiq delegation services + DecisionConcludedListener can be unit-tested
-// against the decidesk event contract. The real classes ship in decidesk
-// (OCA\Decidesk\Event\*); these stubs no-op when the real classes are present.
-if (class_exists('\\OCA\\Decidesk\\Event\\DecisionRequestedEvent') === false) {
- include_once __DIR__ . '/Stubs/Decidesk/Event/DecisionRequestedEvent.php';
-}
-
-if (class_exists('\\OCA\\Decidesk\\Event\\DecisionConcludedEvent') === false) {
- include_once __DIR__ . '/Stubs/Decidesk/Event/DecisionConcludedEvent.php';
+// Decision-event stubs — loaded when the decision app is absent so the dossiq
+// delegation services + DecisionConcludedListener can be unit-tested against its
+// event contract. These stubs no-op when the real classes are present.
+//
+// BOTH NAMESPACES are stubbed. The app renamed OCA\Decidesk -> OCA\Decidiq with
+// no alias, and the production code now resolves whichever exists. Stubbing only
+// the old one left the new spelling unknown to static analysis, which then
+// proved the class_exists() call always false — reporting the resilient lookup
+// as dead code. Both must be resolvable for the resolution to analyse as real.
+foreach (['Decidiq', 'Decidesk'] as $stubNamespace) {
+ foreach (['DecisionRequestedEvent', 'DecisionConcludedEvent'] as $stubEvent) {
+ if (class_exists('\\OCA\\' . $stubNamespace . '\\Event\\' . $stubEvent) === false) {
+ include_once __DIR__ . '/Stubs/' . $stubNamespace . '/Event/' . $stubEvent . '.php';
+ }
+ }
}
// Hermiq's oversight contract. procest resolves it by name so it stays
diff --git a/tests/e2e/docs-screenshots.spec.ts b/tests/e2e/docs-screenshots.spec.ts
index 4ff620262..28ad00ce6 100644
--- a/tests/e2e/docs-screenshots.spec.ts
+++ b/tests/e2e/docs-screenshots.spec.ts
@@ -322,23 +322,15 @@ test.describe('docs: admin track', () => {
await shoot(page, 'admin', '01-configure-case-types-05.png')
})
- test('A2 automatic-actions', async ({ page }) => {
- // docs/tutorials/admin/02-automatic-actions.md
- await go(page, '/settings/automatic-actions')
- await shoot(page, 'admin', '02-automatic-actions-01.png')
- const had = await captureCreateDialog(
- page,
- 'admin',
- '02-automatic-actions-02.png',
- )
- if (!had) {
- await shoot(page, 'admin', '02-automatic-actions-02.png')
- }
- await go(page, '/settings/automatic-actions')
- await shoot(page, 'admin', '02-automatic-actions-03.png')
- await shoot(page, 'admin', '02-automatic-actions-04.png')
- await shoot(page, 'admin', '02-automatic-actions-05.png')
- })
+ // A2 automatic-actions was retired with the page it captured
+ // (page-topology-cleanup C2). Automatic actions are OpenRegister flows now,
+ // so docs/user-guide/admin/02-automatic-actions.md documents the migration
+ // command and the flow editor instead — neither of which is a Dossiq screen,
+ // and OpenRegister's own capture spec owns the Flows page.
+ //
+ // The five 02-automatic-actions-*.png screenshots this produced are stale in
+ // the same way the old page was: they show a create dialog for a record that
+ // nothing executed. The rewritten tutorial no longer references them.
test('A3 admin-settings', async ({ page }) => {
// docs/tutorials/admin/03-admin-settings.md — Dossiq's admin
diff --git a/tests/e2e/spec-coverage/retired-surfaces.spec.ts b/tests/e2e/spec-coverage/retired-surfaces.spec.ts
new file mode 100644
index 000000000..25d2e2a95
--- /dev/null
+++ b/tests/e2e/spec-coverage/retired-surfaces.spec.ts
@@ -0,0 +1,153 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Dossiq Contributors
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * Coverage for surfaces `page-topology-cleanup` RETIRED, and for what replaced
+ * them.
+ *
+ * A retirement is the one change nothing else in the suite can catch. Every
+ * other spec asserts that something renders; delete a page and those specs stay
+ * green by simply not running. So the risk is the opposite of the usual one —
+ * not "the page broke", but "the page is still there, or the thing that was
+ * supposed to replace it never arrived and nobody noticed".
+ *
+ * These tests therefore assert BOTH halves: the old surface no longer renders
+ * its own view, and the replacement is reachable. Asserting only the first half
+ * would pass just as happily on a build where the capability vanished entirely.
+ */
+
+import { test, expect } from '@playwright/test'
+import { navToRoute } from '../helpers/nav'
+
+// NO trackDossiqErrors HERE, deliberately.
+//
+// A retired route falls through to the app root, so a console-error assertion
+// on it grades the DASHBOARD's network traffic, not the retirement — and the
+// dashboard legitimately 404s on every case/task fetch against an instance
+// whose register is not seeded. That made the first version of this spec fail
+// for a reason that had nothing to do with what it was testing. The specs that
+// own the dashboard assert its console cleanliness; these assert that a view is
+// gone and its replacement is present.
+
+test.describe('Retired: automatic-actions settings page (C2)', () => {
+ // The `automaticAction` objects this page administered were never executed
+ // by anything — SideEffectDispatcher runs a separate vocabulary keyed on an
+ // inline type. They migrate to OpenRegister flows via
+ // `occ dossiq:actions:migrate-to-flows`.
+ //
+ // @e2e openspec/changes/page-topology-cleanup/proposal.md
+ test('the retired route no longer renders an automatic-actions view', async ({
+ page,
+ }) => {
+ await navToRoute(page, '/settings/automatic-actions')
+
+ // The create control the page used to own. Asserting on THIS rather than
+ // on a heading is deliberate: a heading can be absent because a page is
+ // still loading, but the create control only exists when the retired
+ // index view is actually mounted.
+ await expect(
+ page.getByRole('button', { name: 'Add Automatic Action' }),
+ ).toHaveCount(0)
+ await expect(page.locator('body')).not.toContainText('Internal Server Error')
+ })
+
+ test('the settings menu deeplinks to OpenRegister flows instead', async ({
+ page,
+ }) => {
+ await navToRoute(page, '/')
+
+ // href, not a click: the target is another app, and following it would
+ // make this a test of OpenRegister's Flows page rather than of dossiq's
+ // menu. The hash is the part that matters — OpenRegister's router is
+ // hash-based, and a link written without it lands on the app root.
+ const link = page.locator('a[href="/apps/openregister/#/flows"]')
+ await expect(link).toHaveCount(1)
+ })
+})
+
+test.describe('Retired: besluitvorming agenda pages (D1)', () => {
+ // decidiq owns agenda-building and meetings, and surfaces them on a case
+ // through the `decidesk-decisions` integration leaf.
+ //
+ // @e2e openspec/changes/page-topology-cleanup/proposal.md
+ test('the agenda compiler route no longer renders its view', async ({
+ page,
+ }) => {
+ await navToRoute(page, '/besluitvorming/agenda')
+
+ // The compiler's own control. A "no error" assertion alone would pass on
+ // a build where the page still renders perfectly well.
+ await expect(
+ page.getByRole('heading', { name: /Agenda ?compiler|Agendacompiler/i }),
+ ).toHaveCount(0)
+ // What DOES happen: the router falls through to the app root.
+ await expect(page).toHaveURL(/\/apps\/dossiq\/?$/)
+ await expect(page.locator('body')).not.toContainText('Internal Server Error')
+ })
+
+ test('the vergadering detail route no longer renders its view', async ({
+ page,
+ }) => {
+ await navToRoute(page, '/besluitvorming/vergaderingen/does-not-exist')
+
+ await expect(page).toHaveURL(/\/apps\/dossiq\/?$/)
+ await expect(page.locator('body')).not.toContainText('Internal Server Error')
+ })
+
+ test('decidiq registers the decisions leaf that replaces them', async ({
+ page,
+ }) => {
+ await navToRoute(page, '/')
+
+ // The half that matters. Without it, "the agenda pages are gone" is
+ // indistinguishable from "besluitvorming was deleted": the leaf is what
+ // carries the capability now, and it is registered by decidiq's init
+ // script on every page, not by dossiq.
+ // THREE OUTCOMES, not two. The previous version returned `null` both when
+ // the registry was absent and when the leaf was missing from it, then
+ // reported every null as "registry not present" — so a real missing leaf
+ // would have been described as an environment problem, and an
+ // environment problem (this repo's CI does not install the decision app)
+ // was reported as a missing leaf. A lookup failure must not wear the same
+ // words as a judgement.
+ const probe = await page.evaluate(() => {
+ const registry = (
+ window as unknown as {
+ OCA?: {
+ OpenRegister?: { integrations?: { list?: () => unknown[] } }
+ }
+ }
+ ).OCA?.OpenRegister?.integrations
+ if (!registry?.list) {
+ return { registry: false as const }
+ }
+
+ const entries = registry.list() as Array<{ id?: string; tab?: unknown }>
+ const found = entries.find((entry) => entry.id === 'decidesk-decisions')
+ return {
+ registry: true as const,
+ ids: entries.map((entry) => entry.id).filter(Boolean),
+ leaf: found
+ ? { id: found.id, hasTab: found.tab !== undefined }
+ : null,
+ }
+ })
+
+ // No registry at all = the decision app is not installed on this
+ // instance. That is an environment fact, not a defect in this repo, and
+ // dossiq's CI does not install it. Skip with the reason stated, rather
+ // than failing red on something this PR cannot affect.
+ test.skip(
+ probe.registry === false,
+ 'OpenRegister integration registry absent — the decision app is not installed on this instance',
+ )
+
+ // Registry present: now a missing leaf IS a real finding, and the message
+ // can name what was actually registered instead of guessing.
+ expect(
+ probe.leaf,
+ `decidesk-decisions leaf not registered; registry holds: ${JSON.stringify(probe.ids)}`,
+ ).not.toBeNull()
+ expect(probe.leaf).toEqual({ id: 'decidesk-decisions', hasTab: true })
+ })
+})
diff --git a/tests/e2e/spec-coverage/settings-pages.spec.ts b/tests/e2e/spec-coverage/settings-pages.spec.ts
index 520690df7..cea8caa64 100644
--- a/tests/e2e/spec-coverage/settings-pages.spec.ts
+++ b/tests/e2e/spec-coverage/settings-pages.spec.ts
@@ -54,11 +54,13 @@ const SETTINGS_PAGES: Array<{ name: string; route: string; addBtn: string }> = [
route: '/settings/parafeerroutes',
addBtn: 'Add Endorsement Route',
},
- {
- name: 'Automatic actions',
- route: '/settings/automatic-actions',
- addBtn: 'Add Automatic Action',
- },
+ // Automatic actions was retired by page-topology-cleanup (C2). The
+ // `automaticAction` objects it administered were never executed by anything
+ // — SideEffectDispatcher runs a separate vocabulary — so the page was a
+ // surface over a capability with no runtime. They migrate to OpenRegister
+ // flows via `occ dossiq:actions:migrate-to-flows`, and the settings menu now
+ // deeplinks to /apps/openregister/#/flows, which OpenRegister's own suite
+ // covers.
{
name: 'Enforcement strategy',
route: '/settings/lhs-matrices',