diff --git a/lib/AppInfo/SigningEventRegistrar.php b/lib/AppInfo/SigningEventRegistrar.php index fb0de1df2..1699bb745 100644 --- a/lib/AppInfo/SigningEventRegistrar.php +++ b/lib/AppInfo/SigningEventRegistrar.php @@ -4,7 +4,7 @@ * Filinq Signing Event Registrar * * Wires the signing-related event listeners: the bridge from OpenRegister's - * ApprovalStep events into Filinq's typed Signer* events, and the cross-app + * task-sequence events into Filinq's typed Signer* events, and the cross-app * delegated-signing request contract. Extracted from `Application`. * * @category AppInfo @@ -26,24 +26,47 @@ namespace OCA\Filinq\AppInfo; use OCA\Filinq\Event\DocumentSigningRequestedEvent; -use OCA\Filinq\EventListener\ApprovalStepListener; use OCA\Filinq\EventListener\DocumentSigningRequestedListener; -use OCA\OpenRegister\Event\ApprovalStepApprovedEvent; -use OCA\OpenRegister\Event\ApprovalStepCompletedEvent; -use OCA\OpenRegister\Event\ApprovalStepInitiatedEvent; -use OCA\OpenRegister\Event\ApprovalStepRejectedEvent; +use OCA\Filinq\EventListener\SigningTaskListener; use OCP\AppFramework\Bootstrap\IRegistrationContext; /** - * Registers the approval-step bridge and the cross-app signing-request listener. + * Registers the task-sequence bridge and the cross-app signing-request listener. * * @category AppInfo * @package OCA\Filinq\AppInfo * @author Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ class SigningEventRegistrar { + + /** + * The OpenRegister task events the signing bridge consumes, as FQN + * string literals on purpose. `::class` on an imported name is a + * compile-time string too, but a literal keeps that true even if + * someone later adds the import — and during our own register() the + * `OCA\OpenRegister\` prefix is not on the autoloader yet, so neither a + * `class_exists()` probe (always false here) nor an eager reference + * (aborts register()) is an option; `BootstrapOrderIndependenceTest` + * pins both rules. Registering for an event class that never comes to + * exist is harmless: the dispatcher keys listeners by name, and the + * name is simply never dispatched. Mapping per openregister#3302 + * (flow-approval-consolidation, approval-events-migration.md): + * transitioned-to-enabled replaces the retired step-initiated signal, + * committed terminality replaces step-approved and step-rejected, and + * sequence completion replaces chain completion. + * + * @var array + */ + public const TASK_EVENTS = [ + 'OCA\\OpenRegister\\Event\\TaskTransitionedEvent', + 'OCA\\OpenRegister\\Event\\TaskTerminalEvent', + 'OCA\\OpenRegister\\Event\\TaskSequenceCompletedEvent', + ]; + /** * Register the signing event listeners. * @@ -51,20 +74,17 @@ class SigningEventRegistrar { * * @return void * - * @spec openspec/specs/document-signing/spec.md + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ public function register(IRegistrationContext $context): void { - // Bridge OR ApprovalStep events into typed filinq Signer*Events - // and invoke the configured SigningProviderInterface when a step - // becomes pending. Per migrate-signing-to-or-approval-workflow - // (D2.1) — OR's `add-approval-step-events` shipped upstream as of - // 2026-06-12 so the four event classes referenced below resolve at - // runtime; if the OR app is absent (degraded install) the listener - // simply never receives the events. - $context->registerEventListener(ApprovalStepInitiatedEvent::class, ApprovalStepListener::class); - $context->registerEventListener(ApprovalStepApprovedEvent::class, ApprovalStepListener::class); - $context->registerEventListener(ApprovalStepRejectedEvent::class, ApprovalStepListener::class); - $context->registerEventListener(ApprovalStepCompletedEvent::class, ApprovalStepListener::class); + // Bridge OR's task-sequence events into typed filinq Signer*Events + // and invoke the configured SigningProviderInterface when a sequence + // position becomes enabled. If the OR app is absent (degraded + // install) or predates the task surface, the listener simply never + // receives the events. + foreach (self::TASK_EVENTS as $taskEvent) { + $context->registerEventListener(event: $taskEvent, listener: SigningTaskListener::class); + } // Cross-app delegated-signing contract (filinq-signing-events): any // installed consumer app (e.g. shillinq) dispatches diff --git a/lib/Event/SignerChainCompletedEvent.php b/lib/Event/SignerChainCompletedEvent.php index 5a37f24b1..745c147ea 100644 --- a/lib/Event/SignerChainCompletedEvent.php +++ b/lib/Event/SignerChainCompletedEvent.php @@ -3,11 +3,15 @@ /** * SignerChainCompletedEvent * - * Typed filinq-side event fired ONCE per signing-request when the final OR - * approval step is approved — i.e. every signer has signed. Bridges OR's - * `ApprovalStepCompletedEvent`. Internal filinq subscribers (notifications, - * downstream archival, signed-document assembly) react to this event in place - * of polling the legacy `SigningService::updateRequestStatus()` flag. + * Typed filinq-side event fired when an OR task sequence belonging to a + * filinq signing-request completes: the final position completed with an + * approving outcome. Bridges OR's `TaskSequenceCompletedEvent`, which is + * dispatched at exactly that moment. Internal filinq subscribers + * (notifications, artifact production, UI state) react here. + * + * Carries scalars only, on purpose: the payload survives with OpenRegister + * older, newer or absent, which is what lets filinq load on either side of + * openregister#3302 (flow-approval-consolidation). * * @category Event * @package OCA\Filinq\Event @@ -20,41 +24,46 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 * - * @spec openspec/changes/migrate-signing-to-or-approval-workflow/tasks.md#D1-2 + * @spec openspec/changes/migrate-signing-to-or-tasks/tasks.md#2-1 */ declare(strict_types=1); namespace OCA\Filinq\Event; -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; use OCP\EventDispatcher\Event; /** - * Fired once when the OR approval chain backing a sign-request completes. + * Fired when the task sequence of a filinq sign-request completes. * * @category Event * @package OCA\Filinq\Event * @author Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ class SignerChainCompletedEvent extends Event { /** * Constructor. * - * @param ApprovalChain $chain The OR approval chain that completed. - * @param ApprovalStep $finalStep The final approved step. - * @param string $userId UID of the user who approved the final step. - * @param string $objectUuid Signing-request UUID. + * @param string $sequenceUuid UUID of the completed OR task sequence. + * @param string $finalTaskUuid UUID of the final position's task. + * @param string|null $userId The identity that decided the final position. + * @param string $statusOnApprove The approving status the frozen + * declaration resolves to. + * @param string $objectUuid UUID of the filinq signing request. * * @return void + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $finalStep, - private readonly string $userId, + private readonly string $sequenceUuid, + private readonly string $finalTaskUuid, + private readonly ?string $userId, + private readonly string $statusOnApprove, private readonly string $objectUuid, ) { parent::__construct(); @@ -62,38 +71,57 @@ public function __construct( }//end __construct() /** - * Get the completed chain. + * Get the sequence UUID. * - * @return ApprovalChain The OR approval chain. + * @return string UUID of the completed OR task sequence. + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getChain(): ApprovalChain { - return $this->chain; - }//end getChain() + public function getSequenceUuid(): string { + return $this->sequenceUuid; + }//end getSequenceUuid() /** - * Get the final approved step. + * Get the final task's UUID. + * + * @return string UUID of the final position's task. * - * @return ApprovalStep The final OR approval step. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getFinalStep(): ApprovalStep { - return $this->finalStep; - }//end getFinalStep() + public function getFinalTaskUuid(): string { + return $this->finalTaskUuid; + }//end getFinalTaskUuid() /** - * Get the UID of the user who approved the final step. + * Get the deciding identity. + * + * @return string|null Who decided the final position, when known. * - * @return string Nextcloud user ID. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getUserId(): string { + public function getUserId(): ?string { return $this->userId; }//end getUserId() /** - * Get the filinq signing-request UUID this chain backed. + * Get the resolved approving status. + * + * @return string The `statusOnApprove` the frozen declaration resolves to. + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md + */ + public function getStatusOnApprove(): string { + return $this->statusOnApprove; + }//end getStatusOnApprove() + + /** + * Get the signing-request object UUID. + * + * @return string UUID of the filinq signing request. * - * @return string Signing-request UUID. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getSigningRequestUuid(): string { + public function getObjectUuid(): string { return $this->objectUuid; - }//end getSigningRequestUuid() + }//end getObjectUuid() }//end class diff --git a/lib/Event/SignerStepApprovedEvent.php b/lib/Event/SignerStepApprovedEvent.php index c2db8dba2..5ae72b80a 100644 --- a/lib/Event/SignerStepApprovedEvent.php +++ b/lib/Event/SignerStepApprovedEvent.php @@ -3,10 +3,17 @@ /** * SignerStepApprovedEvent * - * Typed filinq-side event fired when a `pending` OR approval step linked to - * a filinq signing-request is approved (i.e. a signer signed). Bridges OR's - * `ApprovalStepApprovedEvent`; carries the next step (if any) so internal - * filinq subscribers can decide whether the chain has advanced or stalled. + * Typed filinq-side event fired when a position of an OR task sequence + * belonging to a filinq signing-request completes with an approving outcome. + * Bridges OR's committed `TaskTerminalEvent` (state `completed`, outcome not + * in the rejecting vocabulary). The retired `nextStep` payload is gone by + * design: OR enables the next position in the same request as the approving + * decision, and that position's own enabled transition arrives as a + * `SignerStepPendingEvent`. + * + * Carries scalars only, on purpose: the payload survives with OpenRegister + * older, newer or absent, which is what lets filinq load on either side of + * openregister#3302 (flow-approval-consolidation). * * @category Event * @package OCA\Filinq\Event @@ -19,43 +26,47 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 * - * @spec openspec/changes/migrate-signing-to-or-approval-workflow/tasks.md#D2-1 + * @spec openspec/changes/migrate-signing-to-or-tasks/tasks.md#2-1 */ declare(strict_types=1); namespace OCA\Filinq\Event; -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; use OCP\EventDispatcher\Event; /** - * Fired after an approval step linked to a filinq sign-request is approved. + * Fired when a sequence position linked to a filinq sign-request is approved. * * @category Event * @package OCA\Filinq\Event * @author Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ class SignerStepApprovedEvent extends Event { /** * Constructor. * - * @param ApprovalChain $chain The OR approval chain. - * @param ApprovalStep $step The approved OR approval step. - * @param string $userId UID of the user who approved. - * @param ApprovalStep|null $nextStep Next step now pending (null = final). - * @param string $objectUuid Signing-request UUID. + * @param string $sequenceUuid UUID of the OR task sequence. + * @param string $taskUuid UUID of the completed task. + * @param int $position Ordinal of the position (1-based). + * @param string|null $userId The completing identity (`task.completedBy`). + * @param string|null $comment The completion comment, when one was given. + * @param string $objectUuid UUID of the filinq signing request. * * @return void + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $step, - private readonly string $userId, - private readonly ?ApprovalStep $nextStep, + private readonly string $sequenceUuid, + private readonly string $taskUuid, + private readonly int $position, + private readonly ?string $userId, + private readonly ?string $comment, private readonly string $objectUuid, ) { parent::__construct(); @@ -63,56 +74,68 @@ public function __construct( }//end __construct() /** - * Get the approval chain. + * Get the sequence UUID. + * + * @return string UUID of the OR task sequence. * - * @return ApprovalChain The OR approval chain. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getChain(): ApprovalChain { - return $this->chain; - }//end getChain() + public function getSequenceUuid(): string { + return $this->sequenceUuid; + }//end getSequenceUuid() /** - * Get the approved step. + * Get the completed task's UUID. * - * @return ApprovalStep The OR approval step. + * @return string UUID of the completed task. + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getStep(): ApprovalStep { - return $this->step; - }//end getStep() + public function getTaskUuid(): string { + return $this->taskUuid; + }//end getTaskUuid() /** - * Get the UID of the user who approved this step. + * Get the position ordinal. + * + * @return int Ordinal of the position (1-based). * - * @return string Nextcloud user ID. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getUserId(): string { - return $this->userId; - }//end getUserId() + public function getPosition(): int { + return $this->position; + }//end getPosition() /** - * Get the next step now pending, or null when this was the final step. + * Get the completing identity. + * + * @return string|null Who completed the position, when known. * - * @return ApprovalStep|null Next pending step, or null. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getNextStep(): ?ApprovalStep { - return $this->nextStep; - }//end getNextStep() + public function getUserId(): ?string { + return $this->userId; + }//end getUserId() /** - * Convenience: is this the final step? + * Get the completion comment. + * + * @return string|null The comment, or null when none was given. * - * @return bool True when no next step is pending. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function isFinalStep(): bool { - return $this->nextStep === null; - }//end isFinalStep() + public function getComment(): ?string { + return $this->comment; + }//end getComment() /** - * Get the filinq signing-request UUID this step relates to. + * Get the signing-request object UUID. + * + * @return string UUID of the filinq signing request. * - * @return string Signing-request UUID. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getSigningRequestUuid(): string { + public function getObjectUuid(): string { return $this->objectUuid; - }//end getSigningRequestUuid() + }//end getObjectUuid() }//end class diff --git a/lib/Event/SignerStepPendingEvent.php b/lib/Event/SignerStepPendingEvent.php index c0253e56f..b94b0e1dd 100644 --- a/lib/Event/SignerStepPendingEvent.php +++ b/lib/Event/SignerStepPendingEvent.php @@ -3,18 +3,18 @@ /** * SignerStepPendingEvent * - * Typed filinq-side event fired whenever an OR ApprovalStep becomes `pending` - * for a filinq signing-request — either the first step (chain initiated) or a - * subsequent step (previous step approved). Bridges OR's - * `ApprovalStepInitiatedEvent` and the "next step now pending" branch of - * `ApprovalStepApprovedEvent` into a single filinq-shaped event so - * `SigningProviderInterface` implementations (and any other filinq - * subscriber) can react without depending on OR's event surface directly. + * Typed filinq-side event fired whenever a position of an OR task sequence + * belonging to a filinq signing-request becomes `enabled` — the first + * position at provisioning, or the next position after an approving + * decision (OR enables it in the same request as that decision). Bridges + * OR's committed `TaskTransitionedEvent` (state `enabled`) into a + * filinq-shaped event so `SigningProviderInterface` implementations (and any + * other filinq subscriber) can react without depending on OR's event surface + * directly. * - * Per ADR-022 filinq consumes OR abstractions; this event is the typed - * filinq wrapper that internal filinq components subscribe to in place of - * the bespoke provider-invocation calls the legacy `SigningService` made - * inline. + * Carries scalars only, on purpose: the payload survives with OpenRegister + * older, newer or absent, which is what lets filinq load on either side of + * openregister#3302 (flow-approval-consolidation). * * @category Event * @package OCA\Filinq\Event @@ -27,39 +27,45 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 * - * @spec openspec/changes/migrate-signing-to-or-approval-workflow/tasks.md#D2-1 + * @spec openspec/changes/migrate-signing-to-or-tasks/tasks.md#2-1 */ declare(strict_types=1); namespace OCA\Filinq\Event; -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; use OCP\EventDispatcher\Event; /** - * Fired when an approval step linked to a filinq sign-request becomes pending. + * Fired when a sequence position linked to a filinq sign-request becomes enabled. * * @category Event * @package OCA\Filinq\Event * @author Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ class SignerStepPendingEvent extends Event { /** * Constructor. * - * @param ApprovalChain $chain The OR approval chain. - * @param ApprovalStep $step The OR approval step now in `pending`. + * @param string $sequenceUuid UUID of the OR task sequence. + * @param string $taskUuid UUID of the now-enabled task. + * @param int $position Ordinal of the position (1-based). + * @param string|null $role The position's signer group, when one is set. * @param string $objectUuid UUID of the filinq signing request. * * @return void + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $step, + private readonly string $sequenceUuid, + private readonly string $taskUuid, + private readonly int $position, + private readonly ?string $role, private readonly string $objectUuid, ) { parent::__construct(); @@ -67,29 +73,57 @@ public function __construct( }//end __construct() /** - * Get the approval chain the step belongs to. + * Get the sequence UUID. * - * @return ApprovalChain The OR approval chain. + * @return string UUID of the OR task sequence. + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getChain(): ApprovalChain { - return $this->chain; - }//end getChain() + public function getSequenceUuid(): string { + return $this->sequenceUuid; + }//end getSequenceUuid() /** - * Get the now-pending approval step. + * Get the enabled task's UUID. + * + * @return string UUID of the now-enabled task. * - * @return ApprovalStep The OR approval step. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getStep(): ApprovalStep { - return $this->step; - }//end getStep() + public function getTaskUuid(): string { + return $this->taskUuid; + }//end getTaskUuid() /** - * Get the filinq signing-request UUID this step relates to. + * Get the position ordinal. + * + * @return int Ordinal of the position (1-based). + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md + */ + public function getPosition(): int { + return $this->position; + }//end getPosition() + + /** + * Get the position's signer group. + * + * @return string|null The signer group, or null when none is set. + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md + */ + public function getRole(): ?string { + return $this->role; + }//end getRole() + + /** + * Get the signing-request object UUID. + * + * @return string UUID of the filinq signing request. * - * @return string Signing-request UUID. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getSigningRequestUuid(): string { + public function getObjectUuid(): string { return $this->objectUuid; - }//end getSigningRequestUuid() + }//end getObjectUuid() }//end class diff --git a/lib/Event/SignerStepRejectedEvent.php b/lib/Event/SignerStepRejectedEvent.php index de288f289..a489c9dcb 100644 --- a/lib/Event/SignerStepRejectedEvent.php +++ b/lib/Event/SignerStepRejectedEvent.php @@ -3,9 +3,15 @@ /** * SignerStepRejectedEvent * - * Typed filinq-side event fired when a `pending` OR approval step linked to - * a filinq signing-request is rejected (i.e. a signer declined). Bridges - * OR's `ApprovalStepRejectedEvent`. A rejection terminates the chain. + * Typed filinq-side event fired when a position of an OR task sequence + * belonging to a filinq signing-request completes with a rejecting outcome. + * Bridges OR's committed `TaskTerminalEvent` (state `completed`, outcome in + * the rejecting vocabulary). A rejection closes the sequence: OR terminates + * every remaining position in the same request as the rejecting decision. + * + * Carries scalars only, on purpose: the payload survives with OpenRegister + * older, newer or absent, which is what lets filinq load on either side of + * openregister#3302 (flow-approval-consolidation). * * @category Event * @package OCA\Filinq\Event @@ -18,41 +24,48 @@ * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 * - * @spec openspec/changes/migrate-signing-to-or-approval-workflow/tasks.md#D2-1 + * @spec openspec/changes/migrate-signing-to-or-tasks/tasks.md#2-1 */ declare(strict_types=1); namespace OCA\Filinq\Event; -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; use OCP\EventDispatcher\Event; /** - * Fired after an approval step linked to a filinq sign-request is rejected. + * Fired when a sequence position linked to a filinq sign-request is rejected. * * @category Event * @package OCA\Filinq\Event * @author Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ class SignerStepRejectedEvent extends Event { /** * Constructor. * - * @param ApprovalChain $chain The OR approval chain. - * @param ApprovalStep $step The rejected OR approval step. - * @param string $userId UID of the user who rejected. - * @param string $objectUuid Signing-request UUID. + * @param string $sequenceUuid UUID of the OR task sequence. + * @param string $taskUuid UUID of the completed task. + * @param int $position Ordinal of the position (1-based). + * @param string|null $userId The completing identity (`task.completedBy`). + * @param string|null $comment The rejection comment (mandatory on OR's + * side; null only when the payload lacked it). + * @param string $objectUuid UUID of the filinq signing request. * * @return void + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $step, - private readonly string $userId, + private readonly string $sequenceUuid, + private readonly string $taskUuid, + private readonly int $position, + private readonly ?string $userId, + private readonly ?string $comment, private readonly string $objectUuid, ) { parent::__construct(); @@ -60,38 +73,68 @@ public function __construct( }//end __construct() /** - * Get the approval chain. + * Get the sequence UUID. * - * @return ApprovalChain The OR approval chain. + * @return string UUID of the OR task sequence. + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getChain(): ApprovalChain { - return $this->chain; - }//end getChain() + public function getSequenceUuid(): string { + return $this->sequenceUuid; + }//end getSequenceUuid() /** - * Get the rejected step. + * Get the completed task's UUID. + * + * @return string UUID of the completed task. * - * @return ApprovalStep The OR approval step. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getStep(): ApprovalStep { - return $this->step; - }//end getStep() + public function getTaskUuid(): string { + return $this->taskUuid; + }//end getTaskUuid() /** - * Get the UID of the user who rejected this step. + * Get the position ordinal. * - * @return string Nextcloud user ID. + * @return int Ordinal of the position (1-based). + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getUserId(): string { + public function getPosition(): int { + return $this->position; + }//end getPosition() + + /** + * Get the completing identity. + * + * @return string|null Who rejected the position, when known. + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md + */ + public function getUserId(): ?string { return $this->userId; }//end getUserId() /** - * Get the filinq signing-request UUID this step relates to. + * Get the rejection comment. + * + * @return string|null The comment, or null when the payload lacked it. + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md + */ + public function getComment(): ?string { + return $this->comment; + }//end getComment() + + /** + * Get the signing-request object UUID. + * + * @return string UUID of the filinq signing request. * - * @return string Signing-request UUID. + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function getSigningRequestUuid(): string { + public function getObjectUuid(): string { return $this->objectUuid; - }//end getSigningRequestUuid() + }//end getObjectUuid() }//end class diff --git a/lib/EventListener/ApprovalStepListener.php b/lib/EventListener/ApprovalStepListener.php deleted file mode 100644 index b064c6a1e..000000000 --- a/lib/EventListener/ApprovalStepListener.php +++ /dev/null @@ -1,179 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @version GIT: - * @link https://www.filinq.app - * - * SPDX-FileCopyrightText: 2026 Conduction B.V. - * SPDX-License-Identifier: EUPL-1.2 - * - * @spec openspec/changes/migrate-signing-to-or-approval-workflow/tasks.md#D2-1 - */ - -declare(strict_types=1); - -namespace OCA\Filinq\EventListener; - -use OCA\OpenRegister\Event\ApprovalStepApprovedEvent; -use OCA\OpenRegister\Event\ApprovalStepCompletedEvent; -use OCA\OpenRegister\Event\ApprovalStepInitiatedEvent; -use OCA\OpenRegister\Event\ApprovalStepRejectedEvent; -use OCP\EventDispatcher\Event; -use OCP\EventDispatcher\IEventListener; -use OCP\IAppConfig; -use Psr\Log\LoggerInterface; -use Throwable; - -/** - * Listener for OR ApprovalStep events relevant to filinq signing requests. - * - * @category EventListener - * @package OCA\Filinq\EventListener - * @author Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @link https://www.filinq.app - * - * @implements IEventListener - */ -class ApprovalStepListener implements IEventListener { - /** - * Constructor. - * - * @param SignerEventTranslator $translator Translates OR approval-step - * transitions into filinq signer - * events and notifies the provider. - * @param IAppConfig $config App config (reads the filinq - * signing-request register/schema - * slugs to filter foreign chains). - * @param LoggerInterface $logger Logger. - * - * @return void - */ - public function __construct( - private readonly SignerEventTranslator $translator, - private readonly IAppConfig $config, - private readonly LoggerInterface $logger, - ) { - - }//end __construct() - - /** - * Handle an OR ApprovalStep event. - * - * @param Event $event The OR-dispatched event. - * - * @return void - */ - public function handle(Event $event): void { - if ($this->isFilinqChain(event: $event) === false) { - return; - } - - try { - if ($event instanceof ApprovalStepInitiatedEvent) { - $this->translator->onInitiated(event: $event); - return; - } - - if ($event instanceof ApprovalStepApprovedEvent) { - $this->translator->onApproved(event: $event); - return; - } - - if ($event instanceof ApprovalStepRejectedEvent) { - $this->translator->onRejected(event: $event); - return; - } - - if ($event instanceof ApprovalStepCompletedEvent) { - $this->translator->onCompleted(event: $event); - return; - } - } catch (Throwable $e) { - // The OR ApprovalService event surface is best-effort; a listener - // failure must not break OR's own write-path. Log and move on so - // other listeners (audit, notifications) still run. - $this->logger->error( - 'ApprovalStepListener failed handling ' . get_class($event) . ': ' . $e->getMessage(), - ['exception' => $e] - ); - }//end try - - }//end handle() - - /** - * Decide whether an event belongs to a filinq signing-request chain. - * - * A chain belongs to filinq iff its `registerSlug` + `schemaSlug` match - * the filinq signing-request register + schema configured in app config. - * When neither slug is configured (fresh install, schema not yet imported), - * the listener treats the event as foreign and skips it. - * - * @param Event $event The OR event. - * - * @return bool True when the event targets a filinq signing-request. - */ - private function isFilinqChain(Event $event): bool { - $chain = $this->extractChain(event: $event); - if ($chain === null) { - return false; - } - - $expectedRegister = $this->config->getValueString('filinq', 'signingRequest_register', ''); - $expectedSchema = $this->config->getValueString('filinq', 'signingRequest_schema', ''); - - if ($expectedRegister === '' || $expectedSchema === '') { - return false; - } - - $register = (string)($chain->getRegisterSlug() ?? ''); - $schema = (string)($chain->getSchemaSlug() ?? ''); - - return $register === $expectedRegister && $schema === $expectedSchema; - }//end isFilinqChain() - - /** - * Extract the ApprovalChain from any of the four OR event types. - * - * @param Event $event The OR event. - * - * @return \OCA\OpenRegister\Db\ApprovalChain|null The chain, or null. - */ - private function extractChain(Event $event): ?\OCA\OpenRegister\Db\ApprovalChain { - if ($event instanceof ApprovalStepInitiatedEvent - || $event instanceof ApprovalStepApprovedEvent - || $event instanceof ApprovalStepRejectedEvent - || $event instanceof ApprovalStepCompletedEvent - ) { - return $event->getChain(); - } - - return null; - }//end extractChain() -}//end class diff --git a/lib/EventListener/SignerEventTranslator.php b/lib/EventListener/SignerEventTranslator.php index 84f4cb2d9..9bce151d6 100644 --- a/lib/EventListener/SignerEventTranslator.php +++ b/lib/EventListener/SignerEventTranslator.php @@ -3,10 +3,13 @@ /** * Filinq Signer Event Translator * - * Translates OpenRegister ApprovalStep events into Filinq's own typed signer - * events and notifies the configured signing provider when a step becomes - * pending. Extracted from `ApprovalStepListener`, which keeps only the - * chain-ownership filter and the event routing. + * Translates task-sequence transitions — already reduced to scalars by + * `SigningTaskListener` — into Filinq's own typed signer events, and + * notifies the configured signing provider when a position becomes enabled. + * The listener keeps the OR event surface and the ownership filter; this + * class is pure filinq: scalars in, `Signer*Event`s out. That split is what + * keeps every OpenRegister type out of this file, so it loads with OR + * older, newer or absent (openregister#3302, flow-approval-consolidation). * * @category EventListener * @package OCA\Filinq\EventListener @@ -16,7 +19,7 @@ * @version GIT: * @link https://www.filinq.app * - * @spec openspec/specs/document-signing/spec.md + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md * * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 @@ -31,22 +34,20 @@ use OCA\Filinq\Event\SignerStepPendingEvent; use OCA\Filinq\Event\SignerStepRejectedEvent; use OCA\Filinq\Service\Signing\SigningProviderFactory; -use OCA\OpenRegister\Event\ApprovalStepApprovedEvent; -use OCA\OpenRegister\Event\ApprovalStepCompletedEvent; -use OCA\OpenRegister\Event\ApprovalStepInitiatedEvent; -use OCA\OpenRegister\Event\ApprovalStepRejectedEvent; use OCP\EventDispatcher\IEventDispatcher; use Psr\Log\LoggerInterface; use Throwable; /** - * Re-emits OR approval-step transitions as Filinq signer events. + * Re-emits task-sequence transitions as Filinq signer events. * * @category EventListener * @package OCA\Filinq\EventListener * @author Conduction B.V. * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ class SignerEventTranslator { /** @@ -54,12 +55,14 @@ class SignerEventTranslator { * * @param SigningProviderFactory $providerFactory Provider factory for invoking * the configured provider on - * step-pending transitions. + * position-enabled transitions. * @param IEventDispatcher $dispatcher Dispatcher used to re-emit * typed filinq-side events. * @param LoggerInterface $logger Logger. * * @return void + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ public function __construct( private readonly SigningProviderFactory $providerFactory, @@ -70,136 +73,166 @@ public function __construct( }//end __construct() /** - * Handle a step-initiated event (a step has become `pending`). - * - * @param ApprovalStepInitiatedEvent $event OR initiated event. + * Handle a sequence position becoming enabled (a signer's turn). + * + * Re-emits the typed pending event and invokes the configured provider. + * Both the first position (sequence provisioned) and every next position + * (previous position approved) arrive here: OR enables the next position + * in the same request as the approving decision, so no separate + * "next step" payload exists. + * + * @param string $sequenceUuid UUID of the OR task sequence. + * @param string $taskUuid UUID of the now-enabled task. + * @param int $position Ordinal of the position (1-based). + * @param string|null $role The position's signer group, when one is set. + * @param string $objectUuid Signing-request UUID. * * @return void * - * @spec openspec/specs/document-signing/spec.md + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function onInitiated(ApprovalStepInitiatedEvent $event): void { + public function onPositionEnabled( + string $sequenceUuid, + string $taskUuid, + int $position, + ?string $role, + string $objectUuid, + ): void { $this->dispatcher->dispatchTyped( new SignerStepPendingEvent( - chain: $event->getChain(), - step: $event->getStep(), - objectUuid: $event->getObjectUuid() + sequenceUuid: $sequenceUuid, + taskUuid: $taskUuid, + position: $position, + role: $role, + objectUuid: $objectUuid ) ); - $this->invokeProviderForPendingStep( - objectUuid: $event->getObjectUuid(), - stepOrder: $event->getStep()->getStepOrder() + $this->invokeProviderForEnabledPosition( + objectUuid: $objectUuid, + position: $position ); - }//end onInitiated() + }//end onPositionEnabled() /** - * Handle a step-approved event. - * - * Re-emits the typed approved event and, if the next step is pending, - * invokes the configured provider for that next step's signer. When the - * chain has no next step the corresponding `ApprovalStepCompletedEvent` - * is what closes the chain (handled separately). - * - * @param ApprovalStepApprovedEvent $event OR approved event. + * Handle a sequence task completing with a decision. + * + * Approving outcomes re-emit the typed approved event; rejecting + * outcomes the typed rejected event. When an approval was not the final + * position, the next position's own enabled transition follows through + * {@see onPositionEnabled()}; the final approval additionally arrives + * through {@see onSequenceCompleted()}. + * + * @param string $sequenceUuid UUID of the OR task sequence. + * @param string $taskUuid UUID of the completed task. + * @param int $position Ordinal of the position (1-based). + * @param string|null $userId The completing identity. + * @param string|null $comment The completion comment, when one was given. + * @param string $objectUuid Signing-request UUID. + * @param bool $isRejecting TRUE when the outcome is in the rejecting + * vocabulary. * * @return void * - * @spec openspec/specs/document-signing/spec.md + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function onApproved(ApprovalStepApprovedEvent $event): void { - $objectUuid = $event->getObjectUuid(); - $nextStep = $event->getNextStep(); - - $this->dispatcher->dispatchTyped( - new SignerStepApprovedEvent( - chain: $event->getChain(), - step: $event->getStep(), - userId: $event->getUserId(), - nextStep: $nextStep, - objectUuid: $objectUuid - ) - ); - - if ($nextStep !== null) { - $this->invokeProviderForPendingStep( - objectUuid: $objectUuid, - stepOrder: $nextStep->getStepOrder() + public function onTaskDecided( + string $sequenceUuid, + string $taskUuid, + int $position, + ?string $userId, + ?string $comment, + string $objectUuid, + bool $isRejecting, + ): void { + if ($isRejecting === true) { + $this->dispatcher->dispatchTyped( + new SignerStepRejectedEvent( + sequenceUuid: $sequenceUuid, + taskUuid: $taskUuid, + position: $position, + userId: $userId, + comment: $comment, + objectUuid: $objectUuid + ) ); + return; } - }//end onApproved() - - /** - * Handle a step-rejected event. - * - * @param ApprovalStepRejectedEvent $event OR rejected event. - * - * @return void - * - * @spec openspec/specs/document-signing/spec.md - */ - public function onRejected(ApprovalStepRejectedEvent $event): void { $this->dispatcher->dispatchTyped( - new SignerStepRejectedEvent( - chain: $event->getChain(), - step: $event->getStep(), - userId: $event->getUserId(), - objectUuid: $event->getObjectUuid() + new SignerStepApprovedEvent( + sequenceUuid: $sequenceUuid, + taskUuid: $taskUuid, + position: $position, + userId: $userId, + comment: $comment, + objectUuid: $objectUuid ) ); - }//end onRejected() + }//end onTaskDecided() /** - * Handle a chain-completed event (final step approved). + * Handle a sequence completing (final position approved). * - * @param ApprovalStepCompletedEvent $event OR completed event. + * @param string $sequenceUuid UUID of the completed OR task sequence. + * @param string $finalTaskUuid UUID of the final position's task. + * @param string|null $userId The identity that decided the final position. + * @param string $statusOnApprove The resolved approving status. + * @param string $objectUuid Signing-request UUID. * * @return void * - * @spec openspec/specs/document-signing/spec.md + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ - public function onCompleted(ApprovalStepCompletedEvent $event): void { + public function onSequenceCompleted( + string $sequenceUuid, + string $finalTaskUuid, + ?string $userId, + string $statusOnApprove, + string $objectUuid, + ): void { $this->dispatcher->dispatchTyped( new SignerChainCompletedEvent( - chain: $event->getChain(), - finalStep: $event->getFinalStep(), - userId: $event->getUserId(), - objectUuid: $event->getObjectUuid() + sequenceUuid: $sequenceUuid, + finalTaskUuid: $finalTaskUuid, + userId: $userId, + statusOnApprove: $statusOnApprove, + objectUuid: $objectUuid ) ); - }//end onCompleted() + }//end onSequenceCompleted() /** - * Resolve the active provider and ask it to handle a now-pending step. + * Resolve the active provider and ask it to handle an enabled position. * - * The `NativeSigningProvider` is a no-op for this call today: it waits for - * the filinq UI signer-action endpoint, which translates to OR's - * `ApprovalService::approveStep`. External providers (`ValidSignProvider` - * and future plugins) may use this hook to push a signing-request to the - * external service or send the signer email. + * The `NativeSigningProvider` is a no-op for this call today: it waits + * for the filinq UI signer-action endpoint, whose reply path (once the + * deferred write-path rewrite lands) is `TaskService::complete()` with + * an approving or rejecting outcome. External providers + * (`ValidSignProvider` and future plugins) may use this hook to push a + * signing-request to the external service or send the signer email. * * @param string $objectUuid Signing-request UUID. - * @param int $stepOrder Step order (1-based). + * @param int $position Position ordinal (1-based). * * @return void */ - private function invokeProviderForPendingStep(string $objectUuid, int $stepOrder): void { + private function invokeProviderForEnabledPosition(string $objectUuid, int $position): void { try { $provider = $this->providerFactory->getActiveProvider(); $this->logger->debug( - 'ApprovalStepListener: provider ' . $provider->getIdentifier() - . ' notified that step ' . $stepOrder . ' is pending for sign-request ' . $objectUuid + 'SigningTaskListener: provider ' . $provider->getIdentifier() + . ' notified that position ' . $position . ' is enabled for sign-request ' . $objectUuid ); } catch (Throwable $e) { $this->logger->error( - 'ApprovalStepListener: failed to resolve signing provider for ' . $objectUuid . ': ' . $e->getMessage(), + 'SigningTaskListener: failed to resolve signing provider for ' . $objectUuid . ': ' . $e->getMessage(), ['exception' => $e] ); } - }//end invokeProviderForPendingStep() + }//end invokeProviderForEnabledPosition() }//end class diff --git a/lib/EventListener/SigningTaskListener.php b/lib/EventListener/SigningTaskListener.php new file mode 100644 index 000000000..f60e6c3e2 --- /dev/null +++ b/lib/EventListener/SigningTaskListener.php @@ -0,0 +1,419 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://www.filinq.app + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Filinq\EventListener; + +use OCA\Filinq\Service\SettingsService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Listener for OR task-sequence events relevant to filinq signing requests. + * + * @category EventListener + * @package OCA\Filinq\EventListener + * @author Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.filinq.app + * + * @implements IEventListener + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md + */ +class SigningTaskListener implements IEventListener { + + /** + * FQN of OR's committed task-transition event, as a string on purpose: + * a cross-app class name is a runtime lookup, and a literal cannot + * accidentally autoload or hard-couple (openregister#3302 mapping). + * + * @var string + */ + public const EVENT_TASK_TRANSITIONED = 'OCA\\OpenRegister\\Event\\TaskTransitionedEvent'; + + /** + * FQN of OR's terminal-task event. + * + * @var string + */ + public const EVENT_TASK_TERMINAL = 'OCA\\OpenRegister\\Event\\TaskTerminalEvent'; + + /** + * FQN of OR's sequence-completed event. + * + * @var string + */ + public const EVENT_SEQUENCE_COMPLETED = 'OCA\\OpenRegister\\Event\\TaskSequenceCompletedEvent'; + + /** + * FQN of OR's task-state vocabulary class. + * + * @var string + */ + private const TASK_STATE_CLASS = 'OCA\\OpenRegister\\Service\\Task\\TaskState'; + + /** + * The task state meaning "this position is the one a person can act on". + * + * @var string + */ + private const STATE_ENABLED = 'enabled'; + + /** + * The task state meaning "the work finished with an explicit outcome". + * + * @var string + */ + private const STATE_COMPLETED = 'completed'; + + /** + * OR's published rejecting-outcome vocabulary, as a fallback when + * `TaskState` cannot be resolved. On the live path it always can — OR + * just dispatched the event — so the fallback exists for test + * environments and defence in depth, mirroring + * `TaskState::REJECTING_OUTCOMES` (approval-events-migration.md). + * + * @var array + */ + private const REJECTING_OUTCOMES_FALLBACK = ['rejected', 'returned', 'declined', 'denied']; + + /** + * Constructor. + * + * @param SignerEventTranslator $translator Translates the extracted + * scalars into filinq signer + * events and notifies the provider. + * @param SettingsService $settingsService Resolves the signingRequest + * binding and the object service + * for the ownership check. + * @param LoggerInterface $logger Logger. + * + * @return void + */ + public function __construct( + private readonly SignerEventTranslator $translator, + private readonly SettingsService $settingsService, + private readonly LoggerInterface $logger, + ) { + + }//end __construct() + + /** + * Handle an OR task-sequence event. + * + * @param Event $event The OR-dispatched event. + * + * @return void + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md + */ + public function handle(Event $event): void { + try { + switch ($event::class) { + case self::EVENT_TASK_TRANSITIONED: + $this->handleTransitioned(event: $event); + return; + case self::EVENT_TASK_TERMINAL: + $this->handleTerminal(event: $event); + return; + case self::EVENT_SEQUENCE_COMPLETED: + $this->handleSequenceCompleted(event: $event); + return; + default: + return; + } + } catch (Throwable $e) { + // The task event surface is best-effort for consumers; a listener + // failure must not break OR's own write-path. Log and move on so + // other listeners (audit, notifications) still run. + $this->logger->error( + 'SigningTaskListener failed handling ' . $event::class . ': ' . $e->getMessage(), + ['exception' => $e] + ); + }//end try + + }//end handle() + + /** + * A committed task transition: a position becoming enabled is a signer's + * turn — the replacement for the retired `ApprovalStepInitiatedEvent` + * and the retired approved event's `nextStep` branch. + * + * @param Event $event OR's TaskTransitionedEvent. + * + * @return void + */ + private function handleTransitioned(Event $event): void { + $task = $this->read(subject: $event, getter: 'getTask'); + if (is_object($task) === false) { + return; + } + + if ((string) ($this->read(subject: $task, getter: 'getState') ?? '') !== self::STATE_ENABLED) { + return; + } + + // A transition that keeps an already-enabled task enabled (e.g. a + // reassignment) is not a new turn; announce each position once. + $previousState = $this->read(subject: $event, getter: 'getPreviousState'); + if ((string) ($previousState ?? '') === self::STATE_ENABLED) { + return; + } + + $sequenceUuid = (string) ($this->read(subject: $task, getter: 'getSequenceUuid') ?? ''); + $objectUuid = (string) ($this->read(subject: $task, getter: 'getObjectUuid') ?? ''); + if ($this->isFilinqSequenceTask(sequenceUuid: $sequenceUuid, objectUuid: $objectUuid) === false) { + return; + } + + $candidateGroups = $this->read(subject: $task, getter: 'getCandidateGroups'); + $role = null; + if (is_array($candidateGroups) === true && $candidateGroups !== []) { + $role = (string) reset($candidateGroups); + } + + $this->translator->onPositionEnabled( + sequenceUuid: $sequenceUuid, + taskUuid: (string) ($this->read(subject: $task, getter: 'getUuid') ?? ''), + position: (int) ($this->read(subject: $task, getter: 'getSequencePosition') ?? 0), + role: $role, + objectUuid: $objectUuid + ); + + }//end handleTransitioned() + + /** + * A terminal task: state `completed` on a filinq sequence task is a + * signer's decision — the replacement for the retired approved and + * rejected events, told apart by the outcome vocabulary. Uncommitted + * dispatches (the in-transaction one from TaskMapper) and terminal + * states that are not completions (cancel, moot, run termination) are + * skipped: the retired surface had no equivalent for those. + * + * @param Event $event OR's TaskTerminalEvent. + * + * @return void + */ + private function handleTerminal(Event $event): void { + if ((bool) $this->read(subject: $event, getter: 'isCommitted') === false) { + return; + } + + $task = $this->read(subject: $event, getter: 'getTask'); + if (is_object($task) === false) { + return; + } + + if ((string) ($this->read(subject: $task, getter: 'getState') ?? '') !== self::STATE_COMPLETED) { + return; + } + + $sequenceUuid = (string) ($this->read(subject: $task, getter: 'getSequenceUuid') ?? ''); + $objectUuid = (string) ($this->read(subject: $task, getter: 'getObjectUuid') ?? ''); + if ($this->isFilinqSequenceTask(sequenceUuid: $sequenceUuid, objectUuid: $objectUuid) === false) { + return; + } + + $userId = null; + $completedBy = $this->read(subject: $task, getter: 'getCompletedBy'); + if ($completedBy !== null) { + $userId = (string) $completedBy; + } + + $comment = null; + $rawComment = $this->read(subject: $task, getter: 'getComment'); + if ($rawComment !== null) { + $comment = (string) $rawComment; + } + + $this->translator->onTaskDecided( + sequenceUuid: $sequenceUuid, + taskUuid: (string) ($this->read(subject: $task, getter: 'getUuid') ?? ''), + position: (int) ($this->read(subject: $task, getter: 'getSequencePosition') ?? 0), + userId: $userId, + comment: $comment, + objectUuid: $objectUuid, + isRejecting: $this->isRejectingOutcome( + outcome: (string) ($this->read(subject: $task, getter: 'getOutcome') ?? '') + ) + ); + + }//end handleTerminal() + + /** + * A completed sequence: the final position approved — the replacement + * for the retired `ApprovalStepCompletedEvent`, dispatched by OR at + * exactly the same moment. + * + * @param Event $event OR's TaskSequenceCompletedEvent. + * + * @return void + */ + private function handleSequenceCompleted(Event $event): void { + $sequence = $this->read(subject: $event, getter: 'getSequence'); + $finalTask = $this->read(subject: $event, getter: 'getFinalTask'); + if (is_object($sequence) === false || is_object($finalTask) === false) { + return; + } + + $sequenceUuid = (string) ($this->read(subject: $sequence, getter: 'getUuid') ?? ''); + $objectUuid = (string) ($this->read(subject: $sequence, getter: 'getAnchorObjectUuid') ?? ''); + if ($this->isFilinqSequenceTask(sequenceUuid: $sequenceUuid, objectUuid: $objectUuid) === false) { + return; + } + + $decider = null; + $rawDecider = $this->read(subject: $event, getter: 'getDecider'); + if ($rawDecider !== null) { + $decider = (string) $rawDecider; + } + + $this->translator->onSequenceCompleted( + sequenceUuid: $sequenceUuid, + finalTaskUuid: (string) ($this->read(subject: $finalTask, getter: 'getUuid') ?? ''), + userId: $decider, + statusOnApprove: (string) ($this->read(subject: $event, getter: 'getStatusOnApprove') ?? ''), + objectUuid: $objectUuid + ); + + }//end handleSequenceCompleted() + + /** + * Read one getter off a cross-app object, ducking the type system. + * + * `is_callable()` rather than `method_exists()`, deliberately: OR's + * Task entity serves its getters through `Entity::__call`, for which + * `method_exists()` answers false while the call works fine. A getter + * that is not callable reads as null, and every caller treats null as + * "absent", which fails closed. + * + * @param object $subject The OR event or entity. + * @param string $getter The getter name. + * + * @return mixed The getter's value, or null when not callable. + */ + private function read(object $subject, string $getter): mixed { + if (is_callable([$subject, $getter]) === false) { + return null; + } + + return $subject->{$getter}(); + }//end read() + + /** + * Decide whether a sequence task (or a sequence) belongs to a filinq + * signing-request. + * + * It does iff BOTH hold: the task is part of a sequence (plain workflow + * tasks never reach the object lookup), and its anchor object resolves + * in the configured signingRequest register/schema. With the binding + * unconfigured every event is foreign — fail closed, exactly as the + * retired slug filter did. + * + * @param string $sequenceUuid The task's sequence uuid ('' when none). + * @param string $objectUuid The anchor object uuid ('' when none). + * + * @return bool True when the event targets a filinq signing-request. + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md + */ + private function isFilinqSequenceTask(string $sequenceUuid, string $objectUuid): bool { + if ($sequenceUuid === '' || $objectUuid === '') { + return false; + } + + $binding = $this->settingsService->resolveSigningRequestBinding(); + if ($binding === null) { + return false; + } + + $objectService = $this->settingsService->getObjectService(); + if ($objectService === null) { + return false; + } + + try { + $object = $objectService->find( + id: $objectUuid, + register: $binding['register'], + schema: $binding['schema'] + ); + } catch (Throwable $e) { + // A lookup failure cannot prove ownership: fail closed. + $this->logger->warning( + 'SigningTaskListener: ownership lookup failed for object ' . $objectUuid . ': ' . $e->getMessage() + ); + return false; + } + + return $object !== null; + }//end isFilinqSequenceTask() + + /** + * Classify an outcome against OR's rejecting vocabulary. + * + * Delegates to `TaskState::isRejectingOutcome()` when the class + * resolves — on the live path it always does, because OR just + * dispatched the event — and otherwise falls back to the published + * vocabulary. The `class_exists()` here runs at event time, never at + * register() time, so the bootstrap-order invariant holds. + * + * @param string $outcome The task's outcome. + * + * @return bool True when the outcome is rejecting. + */ + private function isRejectingOutcome(string $outcome): bool { + $classifier = [self::TASK_STATE_CLASS, 'isRejectingOutcome']; + if (class_exists('\\' . self::TASK_STATE_CLASS) === true && is_callable($classifier) === true) { + return (bool) call_user_func($classifier, $outcome); + } + + return in_array(strtolower(trim($outcome)), self::REJECTING_OUTCOMES_FALLBACK, true); + }//end isRejectingOutcome() +}//end class diff --git a/lib/Service/Signing/NativeSigningProvider.php b/lib/Service/Signing/NativeSigningProvider.php index 9cb8e1f16..3e2746cfa 100644 --- a/lib/Service/Signing/NativeSigningProvider.php +++ b/lib/Service/Signing/NativeSigningProvider.php @@ -157,7 +157,7 @@ public function initiateSigning( * Orphan-auth seam (hydra gate-6): a provider-contract status *read*, not * an authorization guard. No native caller — the async status-poll leg is * a pluggable extension point (see SigningProviderInterface::checkStatus); - * the live status surface is OR's ApprovalChain via + * the live status surface is the signing request read via * `SigningController::showRequest`. Classified as a legit plugin seam in * openspec/changes/orphan-auth-remediation/design.md. * diff --git a/lib/Service/Signing/SigningProviderInterface.php b/lib/Service/Signing/SigningProviderInterface.php index d5baf2056..1137d3bca 100644 --- a/lib/Service/Signing/SigningProviderInterface.php +++ b/lib/Service/Signing/SigningProviderInterface.php @@ -38,6 +38,8 @@ interface SigningProviderInterface { * Get the unique identifier for this provider * * @return string The provider identifier + * + * @spec openspec/specs/signing-via-or-approval-with-provider-plugins/spec.md */ public function getIdentifier(): string; @@ -69,7 +71,7 @@ public function initiateSigning( * *read*, not an authorization guard. It intentionally has no native * caller — the async external-provider status-poll leg is a pluggable * extension point implemented by external providers (e.g. ValidSign) and - * invoked through the provider flow, not the live OR-ApprovalChain status + * invoked through the provider flow, not the live signing-request status * path (`SigningController::showRequest`). Classified as a legit plugin * seam in openspec/changes/orphan-auth-remediation/design.md. * diff --git a/lib/Service/Signing/ValidSignProvider.php b/lib/Service/Signing/ValidSignProvider.php index 8ed30a7ae..1cefdd82b 100644 --- a/lib/Service/Signing/ValidSignProvider.php +++ b/lib/Service/Signing/ValidSignProvider.php @@ -108,7 +108,7 @@ public function initiateSigning( * an authorization guard. No native caller — this is the external-provider * status-poll extension point (SigningProviderInterface::checkStatus), * currently a stub pending ValidSign integration; the live status surface - * is OR's ApprovalChain via `SigningController::showRequest`. Classified as + * is the signing request read via `SigningController::showRequest`. Classified as * a legit plugin seam in openspec/changes/orphan-auth-remediation/design.md. * * @param string $externalId The ValidSign package identifier diff --git a/lib/Settings/filinq_mock_register.json b/lib/Settings/filinq_mock_register.json index 7edea7267..67823175c 100644 --- a/lib/Settings/filinq_mock_register.json +++ b/lib/Settings/filinq_mock_register.json @@ -3110,7 +3110,7 @@ "signingRequest": { "deprecated": true, "deprecatedSince": "5.6.0", - "deprecationNote": "The bespoke signing approval-chain (signingRequest + signerRecord step state) is being migrated to OpenRegister's canonical ApprovalChain / ApprovalStep abstraction per openspec/changes/migrate-signing-to-or-approval-workflow. Existing rows remain readable for the transition window; new sign-requests go through OR's ApprovalService and react to ApprovalStep*Event via lib/EventListener/ApprovalStepListener.php. Do not introduce new write-path callers — extend the ApprovalChain side instead.", + "deprecationNote": "The bespoke signing approval-chain (signingRequest + signerRecord step state) is being migrated to OpenRegister's ordered task sequences per openspec/changes/migrate-signing-to-or-tasks. Existing rows remain readable for the transition window; new sign-requests will provision an OR task sequence, drive decisions through TaskService::complete(), and react to the task lifecycle events via lib/EventListener/SigningTaskListener.php. Do not introduce new write-path callers — extend the task-sequence side instead.", "x-openregister-notifications": { "signingCompleted": { "trigger": { @@ -3139,7 +3139,7 @@ "uri": null, "slug": "signingRequest", "title": "Signing Request", - "description": "Ondertekeningsverzoek voor een document. DEPRECATED v1.2.0: bespoke signing approval-chain superseded by OR ApprovalChain/Step abstractions per openspec/changes/migrate-signing-to-or-approval-workflow.", + "description": "Ondertekeningsverzoek voor een document. DEPRECATED v1.2.0: bespoke signing approval-chain superseded by OR task sequences per openspec/changes/migrate-signing-to-or-tasks.", "version": "1.2.0", "summary": "", "icon": "FileSign", diff --git a/lib/Settings/filinq_register.json b/lib/Settings/filinq_register.json index f7a22440a..2d189d29d 100644 --- a/lib/Settings/filinq_register.json +++ b/lib/Settings/filinq_register.json @@ -2032,7 +2032,7 @@ "signingRequest": { "deprecated": true, "deprecatedSince": "5.6.0", - "deprecationNote": "The bespoke signing approval-chain (signingRequest + signerRecord step state) is being migrated to OpenRegister's canonical ApprovalChain / ApprovalStep abstraction per openspec/changes/migrate-signing-to-or-approval-workflow. Existing rows remain readable for the transition window; new sign-requests go through OR's ApprovalService and react to ApprovalStep*Event via lib/EventListener/ApprovalStepListener.php. Do not introduce new write-path callers — extend the ApprovalChain side instead.", + "deprecationNote": "The bespoke signing approval-chain (signingRequest + signerRecord step state) is being migrated to OpenRegister's ordered task sequences per openspec/changes/migrate-signing-to-or-tasks. Existing rows remain readable for the transition window; new sign-requests will provision an OR task sequence, drive decisions through TaskService::complete(), and react to the task lifecycle events via lib/EventListener/SigningTaskListener.php. Do not introduce new write-path callers — extend the task-sequence side instead.", "x-openregister-notifications": { "signingCompleted": { "trigger": { @@ -2061,7 +2061,7 @@ "uri": null, "slug": "signingRequest", "title": "Signing Request", - "description": "Ondertekeningsverzoek voor een document. DEPRECATED v1.2.0: bespoke signing approval-chain superseded by OR ApprovalChain/Step abstractions per openspec/changes/migrate-signing-to-or-approval-workflow.", + "description": "Ondertekeningsverzoek voor een document. DEPRECATED v1.2.0: bespoke signing approval-chain superseded by OR task sequences per openspec/changes/migrate-signing-to-or-tasks.", "version": "1.2.0", "summary": "", "icon": "FileSign", diff --git a/openspec/changes/migrate-signing-to-or-tasks/design.md b/openspec/changes/migrate-signing-to-or-tasks/design.md new file mode 100644 index 000000000..a25f6a61a --- /dev/null +++ b/openspec/changes/migrate-signing-to-or-tasks/design.md @@ -0,0 +1,99 @@ +# Design: migrate-signing-to-or-tasks + +## D-1 One listener, three events, string-literal registration + +The retired bridge registered one listener for four event classes via +`::class` constants. The replacement registers one listener +(`SigningTaskListener`) for three events — `TaskTransitionedEvent`, +`TaskTerminalEvent`, `TaskSequenceCompletedEvent` — by FQN string literal. + +Why strings and not `::class`: `Foo::class` on an imported name is a +compile-time string and never autoloads, but a literal keeps that true even if +someone later re-adds the import (`BootstrapOrderIndependenceTest` pins the +same rule for the MetricsEngine key). Why no `class_exists()` guard at +register() time: filinq sorts before openregister in the app-loading loop, so +the probe is ALWAYS false during register() — dossiq's guarded pattern is +correct only in boot(). Registering a listener for an event class that never +exists is harmless: the dispatcher keys listeners by event name and the name +is simply never dispatched. + +Version window this buys: on OR **older** than #3302 (current development), +`TaskTransitionedEvent` and `TaskTerminalEvent` already exist and fire for +plain tasks; `TaskSequenceCompletedEvent` never fires; no retired class is +referenced anywhere in filinq, so the app loads. On OR **newer** (post-#3302), +all three fire. On OR absent, none fire. Same listener, no shim. + +## D-2 Ownership moves from chain slugs to the anchored object + +The retired filter compared `chain.registerSlug`/`schemaSlug` against the +configured `signingRequest_register`/`_schema`. Task and TaskSequence expose +numeric register/schema IDs, not slugs, and filinq's config stores what the +installer wrote (slug-shaped strings). Rather than resolving slugs to IDs +through more OR surface, ownership is now: **the event's anchor object +(`task.objectUuid`, or `sequence.anchorObjectUuid`) resolves via +`ObjectService::find()` in the configured signingRequest register/schema.** +`find()` already accepts the binding values in whatever form the config holds +— it is the exact call `SigningService` makes with the same values. + +Cost control: the object lookup runs only after two free pre-filters — the +binding must be configured (unconfigured ⇒ foreign, exactly the retired +behaviour) and the task must carry a `sequenceUuid` (plain workflow tasks, +the overwhelming majority of task traffic, never reach the lookup). + +## D-3 The event mapping, applied + +| Signal | Filter | Emits | +|---|---|---| +| `TaskTransitionedEvent` | state `enabled`, previous state not `enabled`, sequence task, ours | `SignerStepPendingEvent` + provider invocation | +| `TaskTerminalEvent` | `isCommitted()`, state `completed`, sequence task, ours | `SignerStepApprovedEvent` or `SignerStepRejectedEvent` by outcome | +| `TaskSequenceCompletedEvent` | anchor object ours | `SignerChainCompletedEvent` | + +- The committed flag: the mapper also dispatches `TaskTerminalEvent` INSIDE + the verb's transaction (`committed: false`) for timer cancellation. filinq + consumes only the after-commit dispatch, per the migration doc. +- Terminal-but-not-completed states (cancel, moot, run termination) emit + nothing: the retired surface had no equivalent event, and inventing one is + not this change's job. +- `nextStep` is not carried anywhere: OR enables the next position in the + same request as the approving decision, so the provider invocation for the + next signer rides that position's own `enabled` transition. +- Approving vs rejecting: delegated to + `OCA\OpenRegister\Service\Task\TaskState::isRejectingOutcome()` when the + class resolves (it always does on the code path — OR just dispatched the + event), with the published vocabulary + (`rejected`, `returned`, `declined`, `denied`) as a literal fallback so the + classification is testable without OR. The `class_exists()` here runs at + event time, never at register() time. + +## D-4 Duck-typing discipline + +`SigningTaskListener` is the ONLY file that touches OR types, and only inside +`handle()`: it routes on `$event::class` string comparison, guards the +event's real accessors with `method_exists()`, and reads Task/TaskSequence +fields through their magic getters (NC `Entity::__call` — which is exactly why +`method_exists()` is NOT used on the entity objects: it answers false for +magic methods). Everything extracted is a scalar before it leaves the +listener. `SignerEventTranslator` and the four `Signer*Event` classes are +pure filinq: scalars in, filinq events out. That is what makes the +boot-without-OR proof meaningful. + +## D-5 The load proof is a separate process, not a stub + +`tests/scripts/boot-without-openregister.php` builds its own autoloader +(filinq `lib/` + the Nextcloud stubs), loads NO OpenRegister stub, asserts +the retired classes are genuinely unresolvable, then force-links every +signing-surface class and runs `SigningEventRegistrar::register()` against a +minimal context. The PHPUnit wrapper execs it and asserts on exit code and +output. In-process tests cannot prove this: the unit bootstrap loads OR stubs +for every other test, and a stub that exists is exactly what the proof must +exclude. + +## D-6 What is deliberately out of scope + +The bespoke write path (`SigningService` object rows) still does not +provision OR sequences; the archived change deferred that (D1.x) and this +change does not smuggle it in. The reply path for that future rewrite is +`TaskService::complete()` with an approving/rejecting outcome (plus +`TaskService::consume()` where an approval authorizes exactly one action); +the spec delta and provider docblocks now say so, so no future reader +re-implements against the retired names. diff --git a/openspec/changes/migrate-signing-to-or-tasks/proposal.md b/openspec/changes/migrate-signing-to-or-tasks/proposal.md new file mode 100644 index 000000000..70590532b --- /dev/null +++ b/openspec/changes/migrate-signing-to-or-tasks/proposal.md @@ -0,0 +1,72 @@ +# Migrate signing to OR task sequences + +## Why + +OpenRegister PR #3302 (`flow-approval-consolidation`) removes the approval-chain +surface filinq's signing bridge is built on: the four `ApprovalStep*Event` +classes, the `ApprovalChain`/`ApprovalStep` entities and mappers, +`ApprovalService`, and the `/api/approval-chains` and `/api/approval-steps` +routes. Nothing is aliased or re-emitted; the retirement inventory +(`tests/fixtures/approval-consolidation/retired-approval-surface.json` on the +OR branch) marks any app still touching that surface as a broken integration. +filinq is the only consumer. Without this change, filinq's signing event bridge +dies the moment #3302 deploys, and its Signer* event classes reference classes +that no longer exist. + +The published replacement mapping is +`docs/development/approval-events-migration.md` on the #3302 branch: + +| Retired | Replacement | +|---|---| +| `ApprovalStepInitiatedEvent` | a sequence task transitioning to `enabled` (`TaskTransitionedEvent`) | +| `ApprovalStepApprovedEvent` | `TaskTerminalEvent` (committed) — state `completed`, approving outcome | +| `ApprovalStepRejectedEvent` | `TaskTerminalEvent` (committed) — state `completed`, rejecting outcome | +| `ApprovalStepCompletedEvent` | `TaskSequenceCompletedEvent` | +| `ApprovalService::approveStep` / `rejectStep` | `TaskService::complete()` with an approving or rejecting outcome | + +## What changes + +- `SigningEventRegistrar` registers one listener (`SigningTaskListener`) for + the three task events, by FQN **string literal** — never `::class` on an OR + name, never `class_exists()` at register() time (the bootstrap-order + invariant `BootstrapOrderIndependenceTest` enforces). +- `ApprovalStepListener` is replaced by `SigningTaskListener`: ownership + filtering moves from chain register/schema slugs to "the task's (or + sequence's anchor) object resolves in the configured signingRequest + register/schema", and the OR event surface is read duck-typed so filinq + loads with OR both older and newer than #3302. +- `SignerEventTranslator` and the four filinq `Signer*Event` classes drop + every `ApprovalChain`/`ApprovalStep` type: the events now carry scalars + (sequence uuid, task uuid, position, actor, comment, object uuid). The + `nextStep` payload is gone by design: the next position's own `enabled` + transition is the signal, delivered in the same request as the approving + decision. +- Docblocks and the register-JSON deprecation notes stop naming the retired + classes and name the task verbs instead. +- The test stubs drop the retired classes and gain `Task`, `TaskSequence`, + `TaskState` and the three events; a new load test proves the app's signing + wiring boots in a process where the retired classes are absent and no OR + stub is loaded. + +## What does NOT change + +- filinq ships **no** `x-openregister-approval-chains` declaration, so there + is no declarative block to migrate (verified against both register JSONs). +- The bespoke `SigningService` write path (signingRequest/signerRecord object + rows) is untouched. The archived + `migrate-signing-to-or-approval-workflow` change deferred that rewrite + (tasks D1.1–D1.3), so filinq has **no live call site** driving + `approveStep`/`rejectStep` to repoint; the reply path + (`TaskService::complete()` / `consume()`) is recorded in the spec for the + deferred write-path rewrite and in the provider docblocks. +- The filinq signing HTTP API is unchanged. + +## Impact + +- Affected specs: `signing-via-or-approval-with-provider-plugins` (MODIFIED — + the OR vocabulary moves from chains/steps to sequences/tasks). +- Affected code: `lib/AppInfo/SigningEventRegistrar.php`, + `lib/EventListener/` (listener replaced, translator rewritten), + `lib/Event/Signer*.php` (4), provider docblocks, register-JSON notes, + `tests/stubs/OpenRegisterStubs.php`, unit tests. +- Must merge in the same train as openregister#3302. diff --git a/openspec/changes/migrate-signing-to-or-tasks/specs/signing-via-or-approval-with-provider-plugins/spec.md b/openspec/changes/migrate-signing-to-or-tasks/specs/signing-via-or-approval-with-provider-plugins/spec.md new file mode 100644 index 000000000..9be893f34 --- /dev/null +++ b/openspec/changes/migrate-signing-to-or-tasks/specs/signing-via-or-approval-with-provider-plugins/spec.md @@ -0,0 +1,274 @@ +# signing-via-or-approval-with-provider-plugins — delta for migrate-signing-to-or-tasks + +OpenRegister's `flow-approval-consolidation` (openregister#3302) retires the +approval-chain surface this spec was written against. The chain/step +vocabulary is replaced by ordered task sequences: a chain is a +`TaskSequence`, a step is a `Task` at a `sequencePosition`, step decisions +are `TaskService::complete()` outcomes, and the four `ApprovalStep*Event` +classes map to `TaskTransitionedEvent` (position enabled), +`TaskTerminalEvent` (decision, committed) and `TaskSequenceCompletedEvent` +(final approval), per the normative mapping in OR's +`docs/development/approval-events-migration.md`. + +## RENAMED Requirements + +- FROM: `### Requirement: Sign-Request Creation SHALL Create an OR ApprovalChain with One Step per Signer` +- TO: `### Requirement: Sign-Request Creation SHALL Provision an OR Task Sequence with One Position per Signer` + +- FROM: `### Requirement: Signer Approval and Decline MUST Emit Via OR's Approval-Workflow API` +- TO: `### Requirement: Signer Approval and Decline MUST Emit Via OR's Task Verbs` + +- FROM: `### Requirement: Signing Providers SHALL Execute on OR ApprovalStep Pending Transition (Event-Driven)` +- TO: `### Requirement: Signing Providers SHALL Execute When a Sequence Position Becomes Enabled (Event-Driven)` + +## MODIFIED Requirements + +### Requirement: Sign-Request Creation SHALL Provision an OR Task Sequence with One Position per Signer + +SHALL be the primary requirement that when a signing request is initiated on a +document, filinq provisions an OR task sequence with one ordered position per +signer. A position's `candidateGroups` carries the signer's NC group. No new +signing-chain rows are written to any filinq-local approval schema. + +The write-path rewrite this requirement describes was deferred by the archived +`migrate-signing-to-or-approval-workflow` change (tasks D1.1–D1.3) and stays +deferred here: `SigningService` still records the bespoke +signingRequest/signerRecord rows. This requirement binds that future rewrite +to the surviving surface so it is never re-attempted against the retired one. + +#### Scenario: Sign request with two signers provisions a two-position sequence + +@e2e exclude deferred write path — sign-request creation still uses the bespoke object rows (archived change D1.x); this scenario binds the future rewrite to the task-sequence surface and has no implementation to drive yet + +- GIVEN a document with UUID `doc-xyz` stored in a filinq OR register +- AND a sign request is initiated with signers in order: `signer-a`, `signer-b` +- WHEN the POST to filinq's sign-request endpoint is called +- THEN a `TaskSequence` SHALL be provisioned in OR with two positions (1, 2) +- AND position 1's task SHALL be `enabled` with `signer-a`'s NC group in `candidateGroups` +- AND position 2's task SHALL be waiting (not yet enabled) + +#### Scenario: Single-signer sign request provisions a one-position sequence + +@e2e exclude deferred write path — same deferral as the two-signer scenario above + +- GIVEN a sign request with a single signer `signer-only` +- WHEN the sign-request endpoint is called +- THEN a `TaskSequence` SHALL be provisioned in OR with one position, its task `enabled` + +### Requirement: Signer Approval and Decline MUST Emit Via OR's Task Verbs + +MUST be the requirement that all signer decisions (sign or decline) on an +OR-backed sequence are emitted through `TaskService::complete()` — an +approving outcome for sign, a rejecting outcome (with the mandatory comment) +for decline — or through OR's task HTTP routes. Where an approval authorizes +exactly one subsequent action, that action MUST record it via +`TaskService::consume()` so the approval cannot silently re-authorize a later +run. filinq MUST NOT update task or sequence state in any local storage path +in parallel with or instead of the task verbs, and MUST NOT reference the +retired `ApprovalService`, its events, or the `/api/approval-chains` and +`/api/approval-steps` routes. + +#### Scenario: Signer signs — the task is completed with an approving outcome + +@e2e exclude deferred write path — no live filinq call site drives task decisions yet (archived change D1.x); the reply-path contract is pinned here for the rewrite + +- GIVEN an enabled sequence task at position 1 for `doc-xyz` +- AND the requesting user is in the position's candidate group +- WHEN the signer completes the signing flow +- THEN filinq SHALL call `TaskService::complete()` with an approving outcome +- AND OR SHALL enable position 2 in the same request + +#### Scenario: Signer declines — the task is completed with a rejecting outcome + +@e2e exclude deferred write path — same deferral; the mandatory-comment refusal is OR's own contract, tested upstream + +- GIVEN an enabled sequence task at position 1 for `doc-xyz` +- WHEN the signer declines with reason "Niet akkoord met de inhoud" +- THEN filinq SHALL call `TaskService::complete()` with a rejecting outcome and the reason as `comment` +- AND OR SHALL close the sequence; no further position is enabled + +### Requirement: Signing Providers SHALL Execute When a Sequence Position Becomes Enabled (Event-Driven) + +SHALL be the requirement that signing providers (NativeSigningProvider and +external provider adapters) are invoked in response to a sequence position's +task transitioning to `enabled`, not by an app-local step cursor. filinq +learns of the transition from `TaskTransitionedEvent` (committed, state +`enabled`); the next position after an approval is enabled by OR in the same +request as the approving decision, so no `nextStep` payload exists or is +needed. Providers capture the signature and return a result without mutating +task state themselves. + +#### Scenario: Provider invoked when a position's task becomes enabled + +@e2e exclude event bridge — requires OR to dispatch task lifecycle events for a provisioned sequence; the listener→translator→provider path is covered by PHPUnit (SigningTaskListenerTest, SignerEventTranslatorTest) + +- GIVEN a sequence task for `doc-xyz` owned by filinq's signingRequest schema +- WHEN OR dispatches a committed `TaskTransitionedEvent` with state `enabled` +- THEN `SignerStepPendingEvent` SHALL be re-dispatched with the sequence uuid, task uuid, position, role and object uuid +- AND the active `SigningProviderInterface` SHALL be invoked for that position +- AND the provider SHALL NOT update task state itself + +#### Scenario: External signing provider invoked on position enabled + +@e2e exclude event bridge — same PHPUnit coverage as the native-provider scenario; the external callback's task-verb reply is part of the deferred write path + +- GIVEN a sign request configured with an external signing provider +- AND the sequence task at position 1 becomes `enabled` +- WHEN OR dispatches the committed `TaskTransitionedEvent` +- THEN filinq SHALL delegate to the configured `SigningProviderInterface` implementation +- AND on callback/completion, filinq SHALL reply through `TaskService::complete()` with the provider's result + +### Requirement: Signing API Surface for Clients SHALL Be Preserved + +SHALL be the requirement that all existing filinq signing endpoints (initiate +sign request, get sign status, cancel sign request) retain their current +request parameters and response shapes. Callers require no changes when +filinq migrates signing-chain state to OR. + +#### Scenario: Existing sign-request endpoint behaves identically after migration + +@e2e exclude API-shape contract — covered by Newman on /api/signing/* and PHPUnit on SigningController; no navigable UI change + +- GIVEN a client calls `POST /api/signing/requests` with the same payload as before migration +- WHEN the request is processed +- THEN the response shape SHALL be identical to the pre-migration response +- AND once the deferred write path lands, the sign request SHALL be backed by an OR task sequence internally + +#### Scenario: Sign status endpoint returns correct state from OR + +@e2e exclude API-shape contract — same Newman/PHPUnit coverage as above + +- GIVEN a sign request backed by an OR task sequence with two positions, one completed and one enabled +- WHEN the client calls `GET /api/signing/requests/{id}` +- THEN the response SHALL indicate one step complete and one step pending, in the same format as the pre-migration response + +### Requirement: MUST NOT Write to Deprecated Signing-Chain Schema + +MUST NOT be violated: after this migration ships, no code path in filinq +creates or updates objects in any app-local signing-chain approval schema. +All new signing chains are OR task sequences. Existing pre-migration +signing-chain rows remain accessible read-only. + +#### Scenario: Migration does not write new rows to deprecated schema + +@e2e exclude backend guard — negative storage assertion, covered by PHPUnit on the signing service write path; no UI surface + +- GIVEN the migration is deployed +- WHEN any filinq endpoint initiates or advances a signing flow +- THEN no object of any deprecated filinq signing-chain schema type SHALL be created +- AND the deprecated schema's object store SHALL contain only pre-migration rows + +### Requirement: Provider Async-Flow Methods Are a Pluggable Extension Seam, Not Authorization Guards + +The `SigningProviderInterface` async-flow methods SHALL be classified as a +pluggable extension seam implemented by external signing providers, namely +`initiateSigning`, `checkStatus`, `downloadSignedDocument` and `cancelSigning`. These +methods SHALL NOT be treated as authorization guards: none makes an access +decision, and `checkStatus` in particular is a status **read** returning +`status`/`signers`/`completedAt`. The current app signing path is synchronous +and drives only `produceSignedArtifact` (plus `supportsLevel`/`getIdentifier`); +the async-flow methods have no native caller by design and are invoked only by +external-provider plugins. The live "get sign status" surface for clients SHALL +remain the signing request read via the authenticated, per-UID-authorized +`SigningController::showRequest` (backed by an OR task sequence once the +deferred write path lands), never `provider->checkStatus`. + +#### Scenario: checkStatus is a status read, not an authorization guard + +@e2e exclude backend classification — unchanged behaviour, PHPUnit-covered; only the OR vocabulary in the prose moved + +- GIVEN a signing request handled by `NativeSigningProvider` +- WHEN `checkStatus` is invoked with the request's external identifier +- THEN it SHALL return the persisted `status`, `signers`, and `completedAt` +- AND it SHALL make no authorization decision and reject no actor +- AND it SHALL be reached only through the pluggable-provider extension flow, + not the app's live status endpoint + +#### Scenario: Live sign-status surface is the authenticated controller path + +@e2e exclude backend auth contract — 401/403 paths covered by PHPUnit on SigningController; unchanged behaviour + +- GIVEN a client requests the status of sign request `{id}` +- WHEN the client calls `GET /api/signing/requests/{id}` +- THEN the request SHALL be served by `SigningController::showRequest` +- AND the caller SHALL be authenticated (`401` when no user session) +- AND the caller SHALL be authorized per-UID against the request owner + (`403` on mismatch) +- AND `provider->checkStatus` SHALL NOT be on this live path + +## ADDED Requirements + +### Requirement: Signer Decisions and Sequence Completion SHALL Be Consumed from the Task Events + +SHALL be the requirement that filinq's signing bridge consumes exactly three +OR events, registered by FQN string literal with no `class_exists()` probe at +register() time: a committed `TaskTransitionedEvent` to `enabled` (position +pending), a committed `TaskTerminalEvent` with state `completed` (approved +when the outcome is not in the rejecting vocabulary, rejected when it is), +and `TaskSequenceCompletedEvent` (final approval). Uncommitted dispatches and +terminal-but-not-completed states (cancel, moot, run termination) SHALL be +ignored. An event belongs to filinq when its anchor object resolves in the +configured signingRequest register/schema; with the binding unconfigured, +every event is foreign. + +#### Scenario: Approving completion re-dispatches SignerStepApprovedEvent + +@e2e exclude event bridge — OR-dispatched events cannot be raised from filinq's Playwright surface; PHPUnit covers the mapping (SigningTaskListenerTest) + +- GIVEN a committed `TaskTerminalEvent` for a sequence task anchored on a filinq signing request +- AND the task's state is `completed` with outcome `approved` +- WHEN the listener handles the event +- THEN `SignerStepApprovedEvent` SHALL be dispatched carrying the sequence uuid, task uuid, position, completing user, comment and object uuid + +#### Scenario: Rejecting completion re-dispatches SignerStepRejectedEvent + +@e2e exclude event bridge — same PHPUnit coverage + +- GIVEN a committed `TaskTerminalEvent` for an owned sequence task +- AND the task's state is `completed` with outcome `rejected` +- WHEN the listener handles the event +- THEN `SignerStepRejectedEvent` SHALL be dispatched with the rejection comment + +#### Scenario: Sequence completion re-dispatches SignerChainCompletedEvent + +@e2e exclude event bridge — same PHPUnit coverage + +- GIVEN a `TaskSequenceCompletedEvent` whose sequence anchors on a filinq signing request +- WHEN the listener handles the event +- THEN `SignerChainCompletedEvent` SHALL be dispatched carrying the sequence uuid, final task uuid, decider, resolved approving status and object uuid + +#### Scenario: Foreign, uncommitted and non-completed events are ignored + +@e2e exclude event bridge — negative filtering, PHPUnit-covered + +- GIVEN a task event that is uncommitted, OR carries no sequence uuid, OR anchors on an object outside the configured signingRequest register/schema, OR is terminal without state `completed` +- WHEN the listener handles the event +- THEN no `Signer*Event` SHALL be dispatched and no provider SHALL be invoked + +### Requirement: The App MUST Load with the Retired Approval Surface Absent + +MUST be the requirement that filinq references none of the retired classes +(`ApprovalChain`, `ApprovalStep`, their mappers, `ApprovalService`, +`ApprovalController`, the four `ApprovalStep*Event` classes) or retired +routes anywhere in `lib/`, and that the signing wiring loads in a PHP +process where those classes are absent and no OpenRegister stub is loaded. +This is what lets filinq deploy on either side of openregister#3302: the +fleet trains are close but not atomic. + +#### Scenario: Signing wiring boots without OpenRegister + +@e2e exclude load-safety proof — a separate-process autoloader experiment (tests/scripts/boot-without-openregister.php), not a browser flow; asserted by RetiredApprovalSurfaceTest + +- GIVEN a PHP process whose autoloader serves filinq's `lib/` and the Nextcloud stubs but no OpenRegister class +- WHEN every signing-surface class is force-linked and `SigningEventRegistrar::register()` runs +- THEN the process SHALL exit cleanly +- AND the retired classes SHALL be unresolvable in that process +- AND the registered event names SHALL contain no retired event class + +#### Scenario: No retired reference survives in lib/ + +@e2e exclude static sweep — a source scan in PHPUnit (RetiredApprovalSurfaceTest), no runtime surface + +- GIVEN the retirement inventory's class and route lists +- WHEN every PHP file under `lib/` and both register JSONs are scanned +- THEN no retired class FQCN and no retired route SHALL appear diff --git a/openspec/changes/migrate-signing-to-or-tasks/tasks.md b/openspec/changes/migrate-signing-to-or-tasks/tasks.md new file mode 100644 index 000000000..d54944834 --- /dev/null +++ b/openspec/changes/migrate-signing-to-or-tasks/tasks.md @@ -0,0 +1,48 @@ +# Tasks: migrate-signing-to-or-tasks + +## 1. Registrar and listener + +- [ ] 1.1 Rewrite `SigningEventRegistrar` to register `SigningTaskListener` + for `TaskTransitionedEvent`, `TaskTerminalEvent` and + `TaskSequenceCompletedEvent` by FQN string literal; drop the four + retired registrations and the retired imports. + **Acceptance:** no retired name in the file; + `BootstrapOrderIndependenceTest` still passes. +- [ ] 1.2 Replace `ApprovalStepListener` with `SigningTaskListener`: + class-string routing, committed/state/sequence pre-filters, anchored + object ownership check via the configured signingRequest binding, + duck-typed extraction to scalars, rejecting-outcome classification with + OR delegation + literal fallback. + **Acceptance:** `SigningTaskListenerTest` covers every filter branch; + manual mutant flips on the ownership guard each fail a test. +- [ ] 1.3 Rewrite `SignerEventTranslator` to the scalar surface + (onPositionEnabled / onTaskDecided / onSequenceCompleted) and keep the + provider invocation on position-enabled. + **Acceptance:** translator references no OR type. + +## 2. filinq event classes + +- [ ] 2.1 Rewrite the four `Signer*Event` classes to scalar payloads + (sequence uuid, task uuid, position, actor, comment, object uuid; + completed adds statusOnApprove). Drop `nextStep` per the mapping. + **Acceptance:** no OR import in `lib/Event/Signer*.php`. + +## 3. Prose and stubs + +- [ ] 3.1 Repoint the provider docblocks (`NativeSigningProvider`, + `ValidSignProvider`, `SigningProviderInterface`) and the register-JSON + deprecation notes from the retired names to the task verbs. +- [ ] 3.2 Drop the retired stubs from `tests/stubs/OpenRegisterStubs.php`; + add truthful `Task`, `TaskSequence`, `TaskState` and the three event + stubs mirroring the #3302 signatures. + +## 4. Tests and proof + +- [ ] 4.1 Replace `ApprovalStepListenerTest` with `SigningTaskListenerTest` + (filters, mapping, provider invocation, error swallowing) and add + `SignerEventTranslatorTest` for the scalar surface. +- [ ] 4.2 Add `RetiredApprovalSurfaceTest`: lib/ + register-JSON sweep + against the retirement inventory, and the separate-process boot proof + (`tests/scripts/boot-without-openregister.php`). +- [ ] 4.3 Full quality pass: phpcs, phpmd, psalm, phpstan, PHPUnit, hydra + gates scoped to the diff — 0 FAIL. diff --git a/tests/scripts/boot-without-openregister.php b/tests/scripts/boot-without-openregister.php new file mode 100644 index 000000000..b16013acb --- /dev/null +++ b/tests/scripts/boot-without-openregister.php @@ -0,0 +1,150 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://www.filinq.app + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/migrate-signing-to-or-tasks/tasks.md#4-2 + */ + +declare(strict_types=1); + +require_once __DIR__ . '/../../vendor/autoload.php'; +require_once __DIR__ . '/../stubs/GlobalStubs.php'; +require_once __DIR__ . '/../stubs/NextcloudStubs.php'; + +// The OCP event-dispatcher contracts ship in vendor/nextcloud/ocp but are +// not classmap-autoloaded (same situation as tests/bootstrap-unit.php). +$ocpEventDispatcherDir = __DIR__ . '/../../vendor/nextcloud/ocp/OCP/EventDispatcher'; +foreach (['Event.php', 'IEventListener.php', 'IEventDispatcher.php'] as $ocpEventFile) { + $ocpEventPath = $ocpEventDispatcherDir . '/' . $ocpEventFile; + if (is_file($ocpEventPath) === true) { + require_once $ocpEventPath; + } +} + +/** + * Fail the experiment loudly. + * + * @param string $message Why the proof failed. + * + * @return never + */ +function bootProofFail(string $message): never { + fwrite(STDERR, 'BOOT-FAIL: ' . $message . "\n"); + exit(1); +}//end bootProofFail() + +// 1. The retired surface must be genuinely unresolvable in this process — +// this is what "stub-free" means. List per the retirement inventory +// (openregister tests/fixtures/approval-consolidation/retired-approval-surface.json). +$retired = [ + 'OCA\\OpenRegister\\Db\\ApprovalChain', + 'OCA\\OpenRegister\\Db\\ApprovalChainMapper', + 'OCA\\OpenRegister\\Db\\ApprovalStep', + 'OCA\\OpenRegister\\Db\\ApprovalStepMapper', + 'OCA\\OpenRegister\\Service\\ApprovalService', + 'OCA\\OpenRegister\\Controller\\ApprovalController', + 'OCA\\OpenRegister\\Event\\ApprovalStepInitiatedEvent', + 'OCA\\OpenRegister\\Event\\ApprovalStepApprovedEvent', + 'OCA\\OpenRegister\\Event\\ApprovalStepRejectedEvent', + 'OCA\\OpenRegister\\Event\\ApprovalStepCompletedEvent', +]; +foreach ($retired as $retiredClass) { + if (class_exists($retiredClass) === true) { + bootProofFail('retired class resolves in this process: ' . $retiredClass); + } +} + +// 2. Every signing-surface class must link with the retired classes absent. +// class_exists(..., true) forces autoload + link, so a signature, parent +// or interface that needs a missing class fatals here. +$signingSurface = [ + 'OCA\\Filinq\\AppInfo\\SigningEventRegistrar', + 'OCA\\Filinq\\EventListener\\SigningTaskListener', + 'OCA\\Filinq\\EventListener\\SignerEventTranslator', + 'OCA\\Filinq\\Event\\SignerStepPendingEvent', + 'OCA\\Filinq\\Event\\SignerStepApprovedEvent', + 'OCA\\Filinq\\Event\\SignerStepRejectedEvent', + 'OCA\\Filinq\\Event\\SignerChainCompletedEvent', +]; +foreach ($signingSurface as $surfaceClass) { + if (class_exists($surfaceClass) === false) { + bootProofFail('signing-surface class does not link: ' . $surfaceClass); + } +} + +// 3. register() must complete against a minimal context, and register no +// retired event name. +$context = new class implements \OCP\AppFramework\Bootstrap\IRegistrationContext { + /** + * The event names listeners were registered for. + * + * @var array + */ + public array $events = []; + + public function registerService(string $name, callable $factory, bool $shared = true): void { + } + + public function registerAlias(string $alias, string $target): void { + } + + public function registerServiceAlias(string $alias, string $target): void { + } + + public function registerParameter(string $name, mixed $value): void { + } + + public function registerEventListener(string $event, string $listener, int $priority = 0): void { + $this->events[] = $event; + } +}; + +(new \OCA\Filinq\AppInfo\SigningEventRegistrar())->register(context: $context); + +$expected = [ + 'OCA\\OpenRegister\\Event\\TaskTransitionedEvent', + 'OCA\\OpenRegister\\Event\\TaskTerminalEvent', + 'OCA\\OpenRegister\\Event\\TaskSequenceCompletedEvent', + 'OCA\\Filinq\\Event\\DocumentSigningRequestedEvent', +]; +if ($context->events !== $expected) { + bootProofFail('unexpected registrations: ' . implode(', ', $context->events)); +} + +foreach ($context->events as $registeredEvent) { + if (str_contains($registeredEvent, 'Approval') === true) { + bootProofFail('a retired event name was registered: ' . $registeredEvent); + } +} + +echo 'BOOT-OK: signing wiring loads with the retired approval surface absent; registered: ' + . implode(', ', $context->events) . "\n"; +exit(0); diff --git a/tests/stubs/NextcloudStubs.php b/tests/stubs/NextcloudStubs.php index 1739f5e46..65ff4b131 100644 --- a/tests/stubs/NextcloudStubs.php +++ b/tests/stubs/NextcloudStubs.php @@ -498,6 +498,17 @@ public function registerServiceAlias(string $alias, string $target): void; * @return void */ public function registerParameter(string $name, mixed $value): void; + + /** + * Register an event listener. + * + * @param string $event Event class name + * @param string $listener Listener class name + * @param int $priority Listener priority + * + * @return void + */ + public function registerEventListener(string $event, string $listener, int $priority = 0): void; }//end interface /** diff --git a/tests/stubs/OpenRegisterStubs.php b/tests/stubs/OpenRegisterStubs.php index 2a9a1fe93..ba49b564b 100644 --- a/tests/stubs/OpenRegisterStubs.php +++ b/tests/stubs/OpenRegisterStubs.php @@ -1749,7 +1749,11 @@ public function getUserFolder(string $userId): \OCP\Files\Folder; namespace OCA\OpenRegister\Db; /** - * Stub for ApprovalChain entity. + * Stub for the Task entity (flow-task-entity). + * + * The real class is an NC Entity whose getters are served by + * `Entity::__call` from `@method` docblocks; the stub declares the ones the + * signing bridge reads explicitly, with the same nullable shapes. * * @category Tests * @package OCA\OpenRegister\Db @@ -1757,156 +1761,65 @@ public function getUserFolder(string $userId): \OCP\Files\Folder; * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app */ -class ApprovalChain { - private ?int $id = null; - private ?string $uuid = null; - private ?string $name = null; - private ?string $registerSlug = null; - private ?string $schemaSlug = null; - /** @var array>|null */ - private ?array $steps = null; - - public function getId(): ?int { - return $this->id; - } - - public function setId(?int $id): void { - $this->id = $id; - } - - public function getUuid(): ?string { - return $this->uuid; - } - - public function setUuid(?string $uuid): void { - $this->uuid = $uuid; - } - - public function getName(): ?string { - return $this->name; - } - - public function setName(?string $name): void { - $this->name = $name; - } - - public function getRegisterSlug(): ?string { - return $this->registerSlug; - } - - public function setRegisterSlug(?string $slug): void { - $this->registerSlug = $slug; - } - - public function getSchemaSlug(): ?string { - return $this->schemaSlug; - } - - public function setSchemaSlug(?string $slug): void { - $this->schemaSlug = $slug; - } - - /** - * @return array>|null - */ - public function getSteps(): ?array { - return $this->steps; - } - +class Task { /** - * @param array>|null $steps + * @param array|null $candidateGroups */ - public function setSteps(?array $steps): void { - $this->steps = $steps; + public function __construct( + private readonly ?string $uuid = null, + private readonly ?string $state = null, + private readonly ?string $outcome = null, + private readonly ?array $candidateGroups = null, + private readonly ?string $objectUuid = null, + private readonly ?string $sequenceUuid = null, + private readonly ?int $sequencePosition = null, + private readonly ?string $completedBy = null, + private readonly ?string $comment = null, + ) { } -}//end class - -/** - * Stub for ApprovalStep entity. - * - * @category Tests - * @package OCA\OpenRegister\Db - * @author Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @link https://www.filinq.app - */ -class ApprovalStep { - private ?string $uuid = null; - private ?int $chainId = null; - private ?string $objectUuid = null; - private int $stepOrder = 0; - private ?string $role = null; - private ?string $status = 'pending'; - private ?string $decidedBy = null; - private ?string $comment = null; public function getUuid(): ?string { return $this->uuid; } - public function setUuid(?string $uuid): void { - $this->uuid = $uuid; + public function getState(): ?string { + return $this->state; } - public function getChainId(): ?int { - return $this->chainId; + public function getOutcome(): ?string { + return $this->outcome; } - public function setChainId(?int $chainId): void { - $this->chainId = $chainId; + /** + * @return array|null + */ + public function getCandidateGroups(): ?array { + return $this->candidateGroups; } public function getObjectUuid(): ?string { return $this->objectUuid; } - public function setObjectUuid(?string $uuid): void { - $this->objectUuid = $uuid; + public function getSequenceUuid(): ?string { + return $this->sequenceUuid; } - public function getStepOrder(): int { - return $this->stepOrder; + public function getSequencePosition(): ?int { + return $this->sequencePosition; } - public function setStepOrder(int $order): void { - $this->stepOrder = $order; - } - - public function getRole(): ?string { - return $this->role; - } - - public function setRole(?string $role): void { - $this->role = $role; - } - - public function getStatus(): ?string { - return $this->status; - } - - public function setStatus(?string $status): void { - $this->status = $status; - } - - public function getDecidedBy(): ?string { - return $this->decidedBy; - } - - public function setDecidedBy(?string $decidedBy): void { - $this->decidedBy = $decidedBy; + public function getCompletedBy(): ?string { + return $this->completedBy; } public function getComment(): ?string { return $this->comment; } - - public function setComment(?string $comment): void { - $this->comment = $comment; - } }//end class /** - * Stub for ApprovalChainMapper. + * Stub for the TaskSequence entity (flow-approval-consolidation). * * @category Tests * @package OCA\OpenRegister\Db @@ -1914,35 +1827,29 @@ public function setComment(?string $comment): void { * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app */ -class ApprovalChainMapper { - public function insert(ApprovalChain $chain): ApprovalChain { - return $chain; +class TaskSequence { + public function __construct( + private readonly ?string $uuid = null, + private readonly ?string $anchorObjectUuid = null, + private readonly ?string $outcome = null, + private readonly ?string $chainKey = null, + ) { } - public function find(int $id): ApprovalChain { - return new ApprovalChain(); + public function getUuid(): ?string { + return $this->uuid; } -}//end class -/** - * Stub for ApprovalStepMapper. - * - * @category Tests - * @package OCA\OpenRegister\Db - * @author Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @link https://www.filinq.app - */ -class ApprovalStepMapper { - public function insert(ApprovalStep $step): ApprovalStep { - return $step; + public function getAnchorObjectUuid(): ?string { + return $this->anchorObjectUuid; } - /** - * @return array - */ - public function findByChain(int $chainId): array { - return []; + public function getOutcome(): ?string { + return $this->outcome; + } + + public function getChainKey(): ?string { + return $this->chainKey; } }//end class @@ -2010,44 +1917,17 @@ public function getSchemaIdentifier(): string { namespace OCA\OpenRegister\Event; -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskSequence; use OCP\EventDispatcher\Event; /** - * Stub for ApprovalStepInitiatedEvent. + * Stub for TaskTransitionedEvent. * - * @category Tests - * @package OCA\OpenRegister\Event - * @author Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @link https://www.filinq.app - */ -class ApprovalStepInitiatedEvent extends Event { - public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $step, - private readonly string $objectUuid, - ) { - parent::__construct(); - } - - public function getChain(): ApprovalChain { - return $this->chain; - } - - public function getStep(): ApprovalStep { - return $this->step; - } - - public function getObjectUuid(): string { - return $this->objectUuid; - } -}//end class - -/** - * Stub for ApprovalStepApprovedEvent. + * Mirrors openregister lib/Event/TaskTransitionedEvent.php on the + * flow-approval-consolidation branch: a committed task lifecycle + * transition, carrying the task, its previous holder/state and the actor. * * @category Tests * @package OCA\OpenRegister\Event @@ -2055,48 +1935,39 @@ public function getObjectUuid(): string { * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app */ -class ApprovalStepApprovedEvent extends Event { +class TaskTransitionedEvent extends Event { public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $step, - private readonly string $userId, - private readonly string $statusOnApprove, - private readonly ?ApprovalStep $nextStep, + private readonly Task $task, + private readonly ?string $previousAssignee = null, + private readonly ?string $previousState = null, + private readonly ?string $actor = null, ) { parent::__construct(); } - public function getChain(): ApprovalChain { - return $this->chain; - } - - public function getStep(): ApprovalStep { - return $this->step; - } - - public function getUserId(): string { - return $this->userId; - } - - public function getStatusOnApprove(): string { - return $this->statusOnApprove; + public function getTask(): Task { + return $this->task; } - public function getNextStep(): ?ApprovalStep { - return $this->nextStep; + public function getPreviousAssignee(): ?string { + return $this->previousAssignee; } - public function isFinalStep(): bool { - return $this->nextStep === null; + public function getPreviousState(): ?string { + return $this->previousState; } - public function getObjectUuid(): string { - return $this->step->getObjectUuid() ?? ''; + public function getActor(): ?string { + return $this->actor; } }//end class /** - * Stub for ApprovalStepRejectedEvent. + * Stub for TaskTerminalEvent. + * + * Mirrors openregister lib/Event/TaskTerminalEvent.php: a task persisted in + * a terminal state, with `committed` telling the after-commit dispatch + * (TaskService) apart from the in-transaction one (TaskMapper). * * @category Tests * @package OCA\OpenRegister\Event @@ -2104,39 +1975,42 @@ public function getObjectUuid(): string { * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app */ -class ApprovalStepRejectedEvent extends Event { +class TaskTerminalEvent extends Event { public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $step, - private readonly string $userId, - private readonly string $statusOnReject, + private readonly Task $task, + private readonly bool $committed = true, ) { parent::__construct(); } - public function getChain(): ApprovalChain { - return $this->chain; + public function getTask(): Task { + return $this->task; } - public function getStep(): ApprovalStep { - return $this->step; + public function isCommitted(): bool { + return $this->committed; } - public function getUserId(): string { - return $this->userId; + public function getTaskUuid(): string { + return (string) $this->task->getUuid(); } - public function getStatusOnReject(): string { - return $this->statusOnReject; + public function getState(): string { + return (string) $this->task->getState(); } - public function getObjectUuid(): string { - return $this->step->getObjectUuid() ?? ''; + public function getOutcome(): ?string { + return $this->task->getOutcome(); } }//end class /** - * Stub for ApprovalStepCompletedEvent. + * Stub for TaskSequenceCompletedEvent. + * + * Mirrors openregister lib/Event/TaskSequenceCompletedEvent.php + * (flow-approval-consolidation): the final position completed with an + * approving outcome; carries the sequence, the final task, the decider and + * the resolved approving status. * * @category Tests * @package OCA\OpenRegister\Event @@ -2144,35 +2018,31 @@ public function getObjectUuid(): string { * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 * @link https://www.filinq.app */ -class ApprovalStepCompletedEvent extends Event { +class TaskSequenceCompletedEvent extends Event { public function __construct( - private readonly ApprovalChain $chain, - private readonly ApprovalStep $finalStep, - private readonly string $userId, + private readonly TaskSequence $sequence, + private readonly Task $finalTask, + private readonly ?string $decider, private readonly string $statusOnApprove, ) { parent::__construct(); } - public function getChain(): ApprovalChain { - return $this->chain; + public function getSequence(): TaskSequence { + return $this->sequence; } - public function getFinalStep(): ApprovalStep { - return $this->finalStep; + public function getFinalTask(): Task { + return $this->finalTask; } - public function getUserId(): string { - return $this->userId; + public function getDecider(): ?string { + return $this->decider; } public function getStatusOnApprove(): string { return $this->statusOnApprove; } - - public function getObjectUuid(): string { - return $this->finalStep->getObjectUuid() ?? ''; - } }//end class /** @@ -2520,3 +2390,29 @@ public function countSearchObjects(array $query = [], bool $_rbac = true, bool $ public function getObject(): ?ObjectEntityInterface; }//end interface }//end if + +namespace OCA\OpenRegister\Service\Task; + +/** + * Stub for TaskState (flow-task-entity): the published outcome vocabulary. + * + * Mirrors `TaskState::REJECTING_OUTCOMES` and `isRejectingOutcome()` on the + * flow-approval-consolidation branch. + * + * @category Tests + * @package OCA\OpenRegister\Service\Task + * @author Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.filinq.app + */ +class TaskState { + public const REJECTING_OUTCOMES = ['rejected', 'returned', 'declined', 'denied']; + + public static function isRejectingOutcome(?string $outcome): bool { + if ($outcome === null) { + return false; + } + + return in_array(strtolower(trim($outcome)), self::REJECTING_OUTCOMES, true); + } +}//end class diff --git a/tests/unit/AppInfo/RetiredApprovalSurfaceTest.php b/tests/unit/AppInfo/RetiredApprovalSurfaceTest.php new file mode 100644 index 000000000..01e54dc00 --- /dev/null +++ b/tests/unit/AppInfo/RetiredApprovalSurfaceTest.php @@ -0,0 +1,165 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.filinq.app + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/migrate-signing-to-or-tasks/tasks.md#4-2 + */ + +declare(strict_types=1); + +namespace OCA\Filinq\Tests\Unit\AppInfo; + +use OCA\Filinq\AppInfo\SigningEventRegistrar; +use PHPUnit\Framework\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; + +/** + * Tests that the retired approval surface is gone and stays gone. + * + * @covers \OCA\Filinq\AppInfo\SigningEventRegistrar + * + * @category Tests + * @package OCA\Filinq\Tests\Unit\AppInfo + * @author Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.filinq.app + */ +final class RetiredApprovalSurfaceTest extends TestCase { + + /** + * The retired FQCN prefixes and routes, per the retirement inventory. + * + * `OCA\OpenRegister\Db\ApprovalChain` also matches the mapper and + * `ApprovalStep` matches its mapper and all four event classes, so the + * prefix list covers the full inventory. + * + * @var array + */ + private const RETIRED_NEEDLES = [ + 'OCA\\OpenRegister\\Db\\ApprovalChain', + 'OCA\\OpenRegister\\Db\\ApprovalStep', + 'OCA\\OpenRegister\\Service\\ApprovalService', + 'OCA\\OpenRegister\\Controller\\ApprovalController', + 'OCA\\OpenRegister\\Event\\ApprovalStep', + '/api/approval-chains', + '/api/approval-steps', + ]; + + /** + * No retired class FQCN and no retired route survives in lib/ or the + * shipped register JSONs. + * + * @return void + */ + public function testNoRetiredReferenceSurvivesInLib(): void { + $libDir = dirname(__DIR__, 3) . '/lib'; + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($libDir)); + foreach ($iterator as $file) { + if ($file instanceof SplFileInfo === false || $file->isFile() === false) { + continue; + } + + if (in_array($file->getExtension(), ['php', 'json'], true) === false) { + continue; + } + + $files[] = $file->getPathname(); + } + + $this->assertNotEmpty($files, 'lib/ scan found no files — the scan itself is broken'); + + $violations = []; + foreach ($files as $path) { + $content = (string) file_get_contents($path); + // Normalise escaped namespace separators (JSON, string literals) + // so one needle form finds both spellings. + $haystack = str_replace('\\\\', '\\', $content); + foreach (self::RETIRED_NEEDLES as $needle) { + if (str_contains($haystack, $needle) === true) { + $violations[] = $path . ' references ' . $needle; + } + } + } + + $this->assertSame( + [], + $violations, + "Retired approval surface still referenced (openregister#3302 removes these):\n" + . implode("\n", $violations) + ); + + }//end testNoRetiredReferenceSurvivesInLib() + + /** + * The registrar's event roster is the task surface, by string literal, + * with no retired name. + * + * @return void + */ + public function testTheRegistrarRosterIsTheTaskSurface(): void { + $this->assertSame( + [ + 'OCA\\OpenRegister\\Event\\TaskTransitionedEvent', + 'OCA\\OpenRegister\\Event\\TaskTerminalEvent', + 'OCA\\OpenRegister\\Event\\TaskSequenceCompletedEvent', + ], + SigningEventRegistrar::TASK_EVENTS + ); + + foreach (SigningEventRegistrar::TASK_EVENTS as $event) { + $this->assertStringNotContainsString('Approval', $event); + } + + }//end testTheRegistrarRosterIsTheTaskSurface() + + /** + * The signing wiring boots in a process where the retired classes are + * absent and no OpenRegister stub is loaded. + * + * The experiment must run OUTSIDE this process: the unit bootstrap + * loads the OR stubs for every other test, and a stub that exists is + * exactly what the proof needs to exclude. The subprocess builds its + * own world (composer autoload + Nextcloud stubs only), asserts the + * retired classes are unresolvable, force-links every signing-surface + * class and runs SigningEventRegistrar::register(). + * + * @return void + */ + public function testSigningWiringBootsWithoutOpenRegister(): void { + $script = dirname(__DIR__, 2) . '/scripts/boot-without-openregister.php'; + $this->assertFileExists($script); + + $output = []; + $exitCode = 1; + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output, $exitCode); + $joined = implode("\n", $output); + + $this->assertSame(0, $exitCode, 'boot proof failed: ' . $joined); + $this->assertStringContainsString('BOOT-OK', $joined); + $this->assertStringNotContainsString('BOOT-FAIL', $joined); + + }//end testSigningWiringBootsWithoutOpenRegister() +}//end class diff --git a/tests/unit/EventListener/ApprovalStepListenerTest.php b/tests/unit/EventListener/ApprovalStepListenerTest.php deleted file mode 100644 index 7452c78f6..000000000 --- a/tests/unit/EventListener/ApprovalStepListenerTest.php +++ /dev/null @@ -1,399 +0,0 @@ - - * @copyright 2026 Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @link https://www.filinq.app - * - * SPDX-FileCopyrightText: 2026 Conduction B.V. - * SPDX-License-Identifier: EUPL-1.2 - * - * @spec openspec/changes/migrate-signing-to-or-approval-workflow/tasks.md#D2-1 - */ - -declare(strict_types=1); - -namespace OCA\Filinq\Tests\Unit\EventListener; - -use OCA\Filinq\Event\SignerChainCompletedEvent; -use OCA\Filinq\Event\SignerStepApprovedEvent; -use OCA\Filinq\Event\SignerStepPendingEvent; -use OCA\Filinq\Event\SignerStepRejectedEvent; -use OCA\Filinq\EventListener\ApprovalStepListener; -use OCA\Filinq\EventListener\SignerEventTranslator; -use OCA\Filinq\Service\Signing\SigningProviderFactory; -use OCA\Filinq\Service\Signing\SigningProviderInterface; -use OCA\OpenRegister\Db\ApprovalChain; -use OCA\OpenRegister\Db\ApprovalStep; -use OCA\OpenRegister\Event\ApprovalStepApprovedEvent; -use OCA\OpenRegister\Event\ApprovalStepCompletedEvent; -use OCA\OpenRegister\Event\ApprovalStepInitiatedEvent; -use OCA\OpenRegister\Event\ApprovalStepRejectedEvent; -use OCP\EventDispatcher\IEventDispatcher; -use OCP\IAppConfig; -use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; -use Psr\Log\LoggerInterface; - -/** - * Tests for ApprovalStepListener. - * - * @category Tests - * @package OCA\Filinq\Tests\Unit\EventListener - * @author Conduction B.V. - * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 - * @link https://www.filinq.app - */ -final class ApprovalStepListenerTest extends TestCase { - - /** - * Provider factory mock. - * - * @var SigningProviderFactory&MockObject - */ - private SigningProviderFactory $providerFactory; - - /** - * Dispatcher mock used for re-emitting typed filinq events. - * - * @var IEventDispatcher&MockObject - */ - private IEventDispatcher $dispatcher; - - /** - * App config mock supplying register / schema slugs. - * - * @var IAppConfig&MockObject - */ - private IAppConfig $config; - - /** - * Logger mock. - * - * @var LoggerInterface&MockObject - */ - private LoggerInterface $logger; - - /** - * Listener under test. - * - * @var ApprovalStepListener - */ - private ApprovalStepListener $listener; - - /** - * Configure mocks for the filinq signing-request slugs. - * - * @return void - */ - protected function setUp(): void { - parent::setUp(); - - $this->providerFactory = $this->createMock(SigningProviderFactory::class); - $this->dispatcher = $this->createMock(IEventDispatcher::class); - $this->config = $this->createMock(IAppConfig::class); - $this->logger = $this->createMock(LoggerInterface::class); - - $this->config->method('getValueString')->willReturnMap( - [ - ['filinq', 'signingRequest_register', '', 'filinq'], - ['filinq', 'signingRequest_schema', '', 'signingRequest'], - ] - ); - - $this->listener = new ApprovalStepListener( - translator: new SignerEventTranslator( - providerFactory: $this->providerFactory, - dispatcher: $this->dispatcher, - logger: $this->logger - ), - config: $this->config, - logger: $this->logger - ); - - }//end setUp() - - /** - * Build a filinq-owned ApprovalChain. - * - * @return ApprovalChain - */ - private function makeFilinqChain(): ApprovalChain { - $chain = new ApprovalChain(); - $chain->setId(101); - $chain->setUuid('chain-101'); - $chain->setRegisterSlug('filinq'); - $chain->setSchemaSlug('signingRequest'); - - return $chain; - }//end makeFilinqChain() - - /** - * Build a step. - * - * @param int $order Step order. - * @param string $objectUuid Object UUID. - * - * @return ApprovalStep - */ - private function makeStep(int $order, string $objectUuid = 'sign-req-1'): ApprovalStep { - $step = new ApprovalStep(); - $step->setUuid('step-' . $order); - $step->setChainId(101); - $step->setObjectUuid($objectUuid); - $step->setStepOrder($order); - $step->setRole('filinq-signers'); - $step->setStatus('pending'); - - return $step; - }//end makeStep() - - /** - * An initiated event on a filinq chain dispatches SignerStepPendingEvent - * and invokes the active provider. - * - * @return void - */ - public function testInitiatedOnFilinqChainDispatchesAndInvokesProvider(): void { - $chain = $this->makeFilinqChain(); - $step = $this->makeStep(order: 1); - - $provider = $this->createMock(SigningProviderInterface::class); - $provider->method('getIdentifier')->willReturn('native'); - $this->providerFactory->expects($this->once()) - ->method('getActiveProvider') - ->willReturn($provider); - - $this->dispatcher->expects($this->once()) - ->method('dispatchTyped') - ->with($this->isInstanceOf(SignerStepPendingEvent::class)); - - $event = new ApprovalStepInitiatedEvent(chain: $chain, step: $step, objectUuid: 'sign-req-1'); - $this->listener->handle($event); - - }//end testInitiatedOnFilinqChainDispatchesAndInvokesProvider() - - /** - * Events for foreign chains (different register/schema) are ignored. - * - * @return void - */ - public function testForeignChainIsIgnored(): void { - $chain = new ApprovalChain(); - $chain->setRegisterSlug('decidesk'); - $chain->setSchemaSlug('decision'); - - $step = $this->makeStep(order: 1); - - $this->providerFactory->expects($this->never())->method('getActiveProvider'); - $this->dispatcher->expects($this->never())->method('dispatchTyped'); - - $event = new ApprovalStepInitiatedEvent(chain: $chain, step: $step, objectUuid: 'decision-99'); - $this->listener->handle($event); - - }//end testForeignChainIsIgnored() - - /** - * Approved with a next step dispatches SignerStepApprovedEvent AND invokes - * the provider for the next pending step. - * - * @return void - */ - public function testApprovedWithNextStepDispatchesAndInvokesProvider(): void { - $chain = $this->makeFilinqChain(); - $step = $this->makeStep(order: 1); - $nextStep = $this->makeStep(order: 2); - - $provider = $this->createMock(SigningProviderInterface::class); - $provider->method('getIdentifier')->willReturn('native'); - $this->providerFactory->expects($this->once()) - ->method('getActiveProvider') - ->willReturn($provider); - - $this->dispatcher->expects($this->once()) - ->method('dispatchTyped') - ->with( - $this->callback( - static function ($event): bool { - return $event instanceof SignerStepApprovedEvent - && $event->isFinalStep() === false - && $event->getUserId() === 'alice'; - } - ) - ); - - $event = new ApprovalStepApprovedEvent( - chain: $chain, - step: $step, - userId: 'alice', - statusOnApprove: 'IN_PROGRESS', - nextStep: $nextStep - ); - $this->listener->handle($event); - - }//end testApprovedWithNextStepDispatchesAndInvokesProvider() - - /** - * Approved with no next step (final) does not invoke the provider — the - * ApprovalStepCompletedEvent will run shortly after and is what closes - * the chain. - * - * @return void - */ - public function testApprovedAsFinalStepDoesNotInvokeProvider(): void { - $chain = $this->makeFilinqChain(); - $step = $this->makeStep(order: 2); - - $this->providerFactory->expects($this->never())->method('getActiveProvider'); - $this->dispatcher->expects($this->once()) - ->method('dispatchTyped') - ->with( - $this->callback( - static function ($event): bool { - return $event instanceof SignerStepApprovedEvent && $event->isFinalStep() === true; - } - ) - ); - - $event = new ApprovalStepApprovedEvent( - chain: $chain, - step: $step, - userId: 'bob', - statusOnApprove: 'COMPLETED', - nextStep: null - ); - $this->listener->handle($event); - - }//end testApprovedAsFinalStepDoesNotInvokeProvider() - - /** - * Rejected event dispatches SignerStepRejectedEvent and never invokes - * the provider. - * - * @return void - */ - public function testRejectedDispatchesAndDoesNotInvokeProvider(): void { - $chain = $this->makeFilinqChain(); - $step = $this->makeStep(order: 1); - - $this->providerFactory->expects($this->never())->method('getActiveProvider'); - $this->dispatcher->expects($this->once()) - ->method('dispatchTyped') - ->with( - $this->callback( - static function ($event): bool { - return $event instanceof SignerStepRejectedEvent - && $event->getUserId() === 'mallory'; - } - ) - ); - - $event = new ApprovalStepRejectedEvent( - chain: $chain, - step: $step, - userId: 'mallory', - statusOnReject: 'DECLINED' - ); - $this->listener->handle($event); - - }//end testRejectedDispatchesAndDoesNotInvokeProvider() - - /** - * Completed event dispatches SignerChainCompletedEvent. - * - * @return void - */ - public function testCompletedDispatchesChainCompletedEvent(): void { - $chain = $this->makeFilinqChain(); - $finalStep = $this->makeStep(order: 3); - - $this->providerFactory->expects($this->never())->method('getActiveProvider'); - $this->dispatcher->expects($this->once()) - ->method('dispatchTyped') - ->with( - $this->callback( - static function ($event): bool { - return $event instanceof SignerChainCompletedEvent - && $event->getUserId() === 'carol'; - } - ) - ); - - $event = new ApprovalStepCompletedEvent( - chain: $chain, - finalStep: $finalStep, - userId: 'carol', - statusOnApprove: 'COMPLETED' - ); - $this->listener->handle($event); - - }//end testCompletedDispatchesChainCompletedEvent() - - /** - * When no signing-request slugs are configured (fresh install), the - * listener treats every event as foreign and skips it. - * - * @return void - */ - public function testUnconfiguredAppSkipsAllEvents(): void { - $config = $this->createMock(IAppConfig::class); - $config->method('getValueString')->willReturn(''); - - $listener = new ApprovalStepListener( - translator: new SignerEventTranslator( - providerFactory: $this->providerFactory, - dispatcher: $this->dispatcher, - logger: $this->logger - ), - config: $config, - logger: $this->logger - ); - - $this->providerFactory->expects($this->never())->method('getActiveProvider'); - $this->dispatcher->expects($this->never())->method('dispatchTyped'); - - $event = new ApprovalStepInitiatedEvent( - chain: $this->makeFilinqChain(), - step: $this->makeStep(order: 1), - objectUuid: 'sign-req-1' - ); - $listener->handle($event); - - }//end testUnconfiguredAppSkipsAllEvents() - - /** - * Provider resolution failure is swallowed — the listener must not throw - * out of `handle()` (OR's write-path runs other listeners after it). - * - * @return void - */ - public function testProviderResolutionFailureIsLoggedNotThrown(): void { - $this->providerFactory->expects($this->once()) - ->method('getActiveProvider') - ->willThrowException(new \RuntimeException('no provider')); - - $this->logger->expects($this->atLeastOnce())->method('error'); - - // Dispatch still happens — the typed event is independent of the - // provider call. - $this->dispatcher->expects($this->once())->method('dispatchTyped'); - - $event = new ApprovalStepInitiatedEvent( - chain: $this->makeFilinqChain(), - step: $this->makeStep(order: 1), - objectUuid: 'sign-req-1' - ); - - $this->listener->handle($event); - - }//end testProviderResolutionFailureIsLoggedNotThrown() -}//end class diff --git a/tests/unit/EventListener/SignerEventTranslatorTest.php b/tests/unit/EventListener/SignerEventTranslatorTest.php new file mode 100644 index 000000000..09b574b47 --- /dev/null +++ b/tests/unit/EventListener/SignerEventTranslatorTest.php @@ -0,0 +1,268 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.filinq.app + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/migrate-signing-to-or-tasks/tasks.md#4-1 + */ + +declare(strict_types=1); + +namespace OCA\Filinq\Tests\Unit\EventListener; + +use OCA\Filinq\Event\SignerChainCompletedEvent; +use OCA\Filinq\Event\SignerStepApprovedEvent; +use OCA\Filinq\Event\SignerStepPendingEvent; +use OCA\Filinq\Event\SignerStepRejectedEvent; +use OCA\Filinq\EventListener\SignerEventTranslator; +use OCA\Filinq\Service\Signing\SigningProviderFactory; +use OCA\Filinq\Service\Signing\SigningProviderInterface; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Tests for SignerEventTranslator. + * + * @covers \OCA\Filinq\EventListener\SignerEventTranslator + * + * @uses \OCA\Filinq\Event\SignerStepPendingEvent + * @uses \OCA\Filinq\Event\SignerStepApprovedEvent + * @uses \OCA\Filinq\Event\SignerStepRejectedEvent + * @uses \OCA\Filinq\Event\SignerChainCompletedEvent + * + * @category Tests + * @package OCA\Filinq\Tests\Unit\EventListener + * @author Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.filinq.app + */ +final class SignerEventTranslatorTest extends TestCase { + + /** + * Provider factory mock. + * + * @var SigningProviderFactory&MockObject + */ + private SigningProviderFactory $providerFactory; + + /** + * Dispatcher mock. + * + * @var IEventDispatcher&MockObject + */ + private IEventDispatcher $dispatcher; + + /** + * Logger mock. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface $logger; + + /** + * Translator under test. + * + * @var SignerEventTranslator + */ + private SignerEventTranslator $translator; + + /** + * Wire the translator with fresh mocks. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->providerFactory = $this->createMock(SigningProviderFactory::class); + $this->dispatcher = $this->createMock(IEventDispatcher::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->translator = new SignerEventTranslator( + providerFactory: $this->providerFactory, + dispatcher: $this->dispatcher, + logger: $this->logger + ); + + }//end setUp() + + /** + * Position enabled: pending event carries the scalars verbatim and the + * active provider is resolved. + * + * @return void + */ + public function testOnPositionEnabledDispatchesPendingAndResolvesProvider(): void { + $provider = $this->createMock(SigningProviderInterface::class); + $provider->method('getIdentifier')->willReturn('native'); + $this->providerFactory->expects($this->once()) + ->method('getActiveProvider') + ->willReturn($provider); + + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with( + $this->callback( + function (Event $emitted): bool { + $this->assertInstanceOf(SignerStepPendingEvent::class, $emitted); + $this->assertSame('seq-1', $emitted->getSequenceUuid()); + $this->assertSame('task-1', $emitted->getTaskUuid()); + $this->assertSame(1, $emitted->getPosition()); + $this->assertSame('signers', $emitted->getRole()); + $this->assertSame('sign-req-1', $emitted->getObjectUuid()); + + return true; + } + ) + ); + + $this->translator->onPositionEnabled( + sequenceUuid: 'seq-1', + taskUuid: 'task-1', + position: 1, + role: 'signers', + objectUuid: 'sign-req-1' + ); + + }//end testOnPositionEnabledDispatchesPendingAndResolvesProvider() + + /** + * A provider-resolution failure on position-enabled is logged, not + * thrown, and the pending event is still dispatched first. + * + * @return void + */ + public function testProviderResolutionFailureIsLoggedNotThrown(): void { + $this->providerFactory->method('getActiveProvider') + ->willThrowException(new RuntimeException('no provider configured')); + $this->logger->expects($this->once())->method('error'); + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->isInstanceOf(SignerStepPendingEvent::class)); + + $this->translator->onPositionEnabled( + sequenceUuid: 'seq-1', + taskUuid: 'task-1', + position: 1, + role: null, + objectUuid: 'sign-req-1' + ); + + }//end testProviderResolutionFailureIsLoggedNotThrown() + + /** + * A non-rejecting decision dispatches the approved event. + * + * @return void + */ + public function testApprovingDecisionDispatchesApproved(): void { + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with( + $this->callback( + function (Event $emitted): bool { + $this->assertInstanceOf(SignerStepApprovedEvent::class, $emitted); + $this->assertSame('alice', $emitted->getUserId()); + $this->assertSame('akkoord', $emitted->getComment()); + + return true; + } + ) + ); + + $this->translator->onTaskDecided( + sequenceUuid: 'seq-1', + taskUuid: 'task-1', + position: 1, + userId: 'alice', + comment: 'akkoord', + objectUuid: 'sign-req-1', + isRejecting: false + ); + + }//end testApprovingDecisionDispatchesApproved() + + /** + * A rejecting decision dispatches the rejected event, never the + * approved one. + * + * @return void + */ + public function testRejectingDecisionDispatchesRejected(): void { + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with( + $this->callback( + function (Event $emitted): bool { + $this->assertInstanceOf(SignerStepRejectedEvent::class, $emitted); + $this->assertSame('niet akkoord', $emitted->getComment()); + + return true; + } + ) + ); + + $this->translator->onTaskDecided( + sequenceUuid: 'seq-1', + taskUuid: 'task-1', + position: 1, + userId: 'alice', + comment: 'niet akkoord', + objectUuid: 'sign-req-1', + isRejecting: true + ); + + }//end testRejectingDecisionDispatchesRejected() + + /** + * Sequence completion dispatches the chain-completed event verbatim. + * + * @return void + */ + public function testOnSequenceCompletedDispatchesChainCompleted(): void { + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with( + $this->callback( + function (Event $emitted): bool { + $this->assertInstanceOf(SignerChainCompletedEvent::class, $emitted); + $this->assertSame('seq-1', $emitted->getSequenceUuid()); + $this->assertSame('task-9', $emitted->getFinalTaskUuid()); + $this->assertSame('bob', $emitted->getUserId()); + $this->assertSame('signed', $emitted->getStatusOnApprove()); + $this->assertSame('sign-req-1', $emitted->getObjectUuid()); + + return true; + } + ) + ); + + $this->translator->onSequenceCompleted( + sequenceUuid: 'seq-1', + finalTaskUuid: 'task-9', + userId: 'bob', + statusOnApprove: 'signed', + objectUuid: 'sign-req-1' + ); + + }//end testOnSequenceCompletedDispatchesChainCompleted() +}//end class diff --git a/tests/unit/EventListener/SigningTaskListenerTest.php b/tests/unit/EventListener/SigningTaskListenerTest.php new file mode 100644 index 000000000..b1b68b65c --- /dev/null +++ b/tests/unit/EventListener/SigningTaskListenerTest.php @@ -0,0 +1,603 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.filinq.app + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/migrate-signing-to-or-tasks/tasks.md#4-1 + */ + +declare(strict_types=1); + +namespace OCA\Filinq\Tests\Unit\EventListener; + +use OCA\Filinq\Event\SignerChainCompletedEvent; +use OCA\Filinq\Event\SignerStepApprovedEvent; +use OCA\Filinq\Event\SignerStepPendingEvent; +use OCA\Filinq\Event\SignerStepRejectedEvent; +use OCA\Filinq\EventListener\SignerEventTranslator; +use OCA\Filinq\EventListener\SigningTaskListener; +use OCA\Filinq\Service\SettingsService; +use OCA\Filinq\Service\Signing\SigningProviderFactory; +use OCA\Filinq\Service\Signing\SigningProviderInterface; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Db\TaskSequence; +use OCA\OpenRegister\Event\TaskSequenceCompletedEvent; +use OCA\OpenRegister\Event\TaskTerminalEvent; +use OCA\OpenRegister\Event\TaskTransitionedEvent; +use OCA\OpenRegister\Service\ObjectService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; +use stdClass; + +/** + * Tests for SigningTaskListener. + * + * @covers \OCA\Filinq\EventListener\SigningTaskListener + * + * @uses \OCA\Filinq\EventListener\SignerEventTranslator + * @uses \OCA\Filinq\Event\SignerStepPendingEvent + * @uses \OCA\Filinq\Event\SignerStepApprovedEvent + * @uses \OCA\Filinq\Event\SignerStepRejectedEvent + * @uses \OCA\Filinq\Event\SignerChainCompletedEvent + * + * @category Tests + * @package OCA\Filinq\Tests\Unit\EventListener + * @author Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @link https://www.filinq.app + */ +final class SigningTaskListenerTest extends TestCase { + + /** + * UUID of the owned signing-request object every helper uses. + * + * @var string + */ + private const OWNED_OBJECT = 'sign-req-1'; + + /** + * Provider factory mock. + * + * @var SigningProviderFactory&MockObject + */ + private SigningProviderFactory $providerFactory; + + /** + * Dispatcher mock used for re-emitting typed filinq events. + * + * @var IEventDispatcher&MockObject + */ + private IEventDispatcher $dispatcher; + + /** + * Settings service mock supplying the binding and the object service. + * + * @var SettingsService&MockObject + */ + private SettingsService $settingsService; + + /** + * Object service mock backing the ownership lookup. + * + * @var ObjectService&MockObject + */ + private ObjectService $objectService; + + /** + * Logger mock. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface $logger; + + /** + * Listener under test. + * + * @var SigningTaskListener + */ + private SigningTaskListener $listener; + + /** + * Configure mocks: a configured binding and an object store that + * resolves exactly the owned signing-request UUID. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->providerFactory = $this->createMock(SigningProviderFactory::class); + $this->dispatcher = $this->createMock(IEventDispatcher::class); + $this->settingsService = $this->createMock(SettingsService::class); + $this->objectService = $this->createMock(ObjectService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->settingsService->method('resolveSigningRequestBinding') + ->willReturn(['register' => 'filinq', 'schema' => 'signingRequest']); + $this->settingsService->method('getObjectService') + ->willReturn($this->objectService); + $this->objectService->method('find')->willReturnCallback( + function (string $id = '', string $register = '', string $schema = ''): ?stdClass { + if ($id === self::OWNED_OBJECT && $register === 'filinq' && $schema === 'signingRequest') { + return new stdClass(); + } + + return null; + } + ); + + $this->listener = $this->makeListener(settingsService: $this->settingsService); + + }//end setUp() + + /** + * Build a listener around a specific settings-service mock. + * + * @param SettingsService&MockObject $settingsService The settings mock. + * + * @return SigningTaskListener + */ + private function makeListener(SettingsService $settingsService): SigningTaskListener { + return new SigningTaskListener( + translator: new SignerEventTranslator( + providerFactory: $this->providerFactory, + dispatcher: $this->dispatcher, + logger: $this->logger + ), + settingsService: $settingsService, + logger: $this->logger + ); + }//end makeListener() + + /** + * Build a sequence task. + * + * @param string $state Task state. + * @param string|null $outcome Task outcome. + * @param string|null $sequenceUuid Sequence uuid (null = plain task). + * @param string $objectUuid Anchor object uuid. + * + * @return Task + */ + private function makeTask( + string $state, + ?string $outcome = null, + ?string $sequenceUuid = 'seq-1', + string $objectUuid = self::OWNED_OBJECT, + ): Task { + return new Task( + uuid: 'task-1', + state: $state, + outcome: $outcome, + candidateGroups: ['filinq-signers'], + objectUuid: $objectUuid, + sequenceUuid: $sequenceUuid, + sequencePosition: 2, + completedBy: 'alice', + comment: 'akkoord' + ); + }//end makeTask() + + /** + * A committed transition to enabled on an owned sequence task dispatches + * SignerStepPendingEvent with the extracted scalars and invokes the + * active provider. + * + * @return void + */ + public function testEnabledTransitionDispatchesPendingAndInvokesProvider(): void { + $provider = $this->createMock(SigningProviderInterface::class); + $provider->method('getIdentifier')->willReturn('native'); + $this->providerFactory->expects($this->once()) + ->method('getActiveProvider') + ->willReturn($provider); + + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with( + $this->callback( + function (Event $emitted): bool { + $this->assertInstanceOf(SignerStepPendingEvent::class, $emitted); + $this->assertSame('seq-1', $emitted->getSequenceUuid()); + $this->assertSame('task-1', $emitted->getTaskUuid()); + $this->assertSame(2, $emitted->getPosition()); + $this->assertSame('filinq-signers', $emitted->getRole()); + $this->assertSame(self::OWNED_OBJECT, $emitted->getObjectUuid()); + + return true; + } + ) + ); + + $event = new TaskTransitionedEvent( + task: $this->makeTask(state: 'enabled'), + previousState: 'available' + ); + $this->listener->handle($event); + + }//end testEnabledTransitionDispatchesPendingAndInvokesProvider() + + /** + * A transition that keeps an enabled task enabled (e.g. reassignment) + * is not announced again. + * + * @return void + */ + public function testTransitionOfAlreadyEnabledTaskIsIgnored(): void { + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + $this->providerFactory->expects($this->never())->method('getActiveProvider'); + + $event = new TaskTransitionedEvent( + task: $this->makeTask(state: 'enabled'), + previousState: 'enabled' + ); + $this->listener->handle($event); + + }//end testTransitionOfAlreadyEnabledTaskIsIgnored() + + /** + * Transitions to states other than enabled emit nothing. + * + * @return void + */ + public function testNonEnabledTransitionIsIgnored(): void { + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + + $event = new TaskTransitionedEvent(task: $this->makeTask(state: 'active')); + $this->listener->handle($event); + + }//end testNonEnabledTransitionIsIgnored() + + /** + * A plain workflow task (no sequence uuid) never reaches the object + * lookup: sequence membership is the cheap pre-filter. + * + * @return void + */ + public function testTaskWithoutSequenceNeverReachesTheObjectLookup(): void { + $this->objectService->expects($this->never())->method('find'); + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + + $event = new TaskTransitionedEvent( + task: $this->makeTask(state: 'enabled', sequenceUuid: null), + previousState: 'available' + ); + $this->listener->handle($event); + + }//end testTaskWithoutSequenceNeverReachesTheObjectLookup() + + /** + * A task with no anchor object never reaches the store either: an empty + * id must not be handed to the object lookup. + * + * @return void + */ + public function testTaskWithoutAnchorObjectNeverReachesTheObjectLookup(): void { + $this->objectService->expects($this->never())->method('find'); + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + + $event = new TaskTransitionedEvent( + task: $this->makeTask(state: 'enabled', objectUuid: ''), + previousState: 'available' + ); + $this->listener->handle($event); + + }//end testTaskWithoutAnchorObjectNeverReachesTheObjectLookup() + + /** + * A sequence task anchored on a foreign object is ignored. + * + * @return void + */ + public function testForeignAnchorObjectIsIgnored(): void { + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + $this->providerFactory->expects($this->never())->method('getActiveProvider'); + + $event = new TaskTransitionedEvent( + task: $this->makeTask(state: 'enabled', objectUuid: 'someone-elses-object'), + previousState: 'available' + ); + $this->listener->handle($event); + + }//end testForeignAnchorObjectIsIgnored() + + /** + * With the binding unconfigured every event is foreign — fail closed, + * and the object lookup is never attempted. + * + * @return void + */ + public function testUnconfiguredBindingSkipsEverything(): void { + $settings = $this->createMock(SettingsService::class); + $settings->method('resolveSigningRequestBinding')->willReturn(null); + $settings->expects($this->never())->method('getObjectService'); + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + + $listener = $this->makeListener(settingsService: $settings); + $listener->handle( + new TaskTransitionedEvent( + task: $this->makeTask(state: 'enabled'), + previousState: 'available' + ) + ); + + }//end testUnconfiguredBindingSkipsEverything() + + /** + * With no object service available ownership cannot be proven: skip. + * + * @return void + */ + public function testMissingObjectServiceFailsClosed(): void { + $settings = $this->createMock(SettingsService::class); + $settings->method('resolveSigningRequestBinding') + ->willReturn(['register' => 'filinq', 'schema' => 'signingRequest']); + $settings->method('getObjectService')->willReturn(null); + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + // The skip must be the guard's clean verdict, not a swallowed crash + // from calling find() on null (the crash path logs a warning, the + // outer catch an error; a clean skip logs neither). + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('warning'); + + $listener = $this->makeListener(settingsService: $settings); + $listener->handle( + new TaskTransitionedEvent( + task: $this->makeTask(state: 'enabled'), + previousState: 'available' + ) + ); + + }//end testMissingObjectServiceFailsClosed() + + /** + * An ownership lookup that throws proves nothing: fail closed and log. + * + * @return void + */ + public function testOwnershipLookupFailureFailsClosed(): void { + $settings = $this->createMock(SettingsService::class); + $settings->method('resolveSigningRequestBinding') + ->willReturn(['register' => 'filinq', 'schema' => 'signingRequest']); + $objectService = $this->createMock(ObjectService::class); + $objectService->method('find')->willThrowException(new RuntimeException('store down')); + $settings->method('getObjectService')->willReturn($objectService); + + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + $this->logger->expects($this->once())->method('warning'); + + $listener = $this->makeListener(settingsService: $settings); + $listener->handle( + new TaskTransitionedEvent( + task: $this->makeTask(state: 'enabled'), + previousState: 'available' + ) + ); + + }//end testOwnershipLookupFailureFailsClosed() + + /** + * The in-transaction dispatch (committed=false) is skipped; only the + * after-commit dispatch is consumed, per the migration mapping. + * + * @return void + */ + public function testUncommittedTerminalDispatchIsIgnored(): void { + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + + $event = new TaskTerminalEvent( + task: $this->makeTask(state: 'completed', outcome: 'approved'), + committed: false + ); + $this->listener->handle($event); + + }//end testUncommittedTerminalDispatchIsIgnored() + + /** + * A committed completion with an approving outcome re-dispatches + * SignerStepApprovedEvent carrying the completing identity and comment. + * + * @return void + */ + public function testApprovingCompletionDispatchesApproved(): void { + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with( + $this->callback( + function (Event $emitted): bool { + $this->assertInstanceOf(SignerStepApprovedEvent::class, $emitted); + $this->assertSame('seq-1', $emitted->getSequenceUuid()); + $this->assertSame('task-1', $emitted->getTaskUuid()); + $this->assertSame(2, $emitted->getPosition()); + $this->assertSame('alice', $emitted->getUserId()); + $this->assertSame('akkoord', $emitted->getComment()); + $this->assertSame(self::OWNED_OBJECT, $emitted->getObjectUuid()); + + return true; + } + ) + ); + + $event = new TaskTerminalEvent(task: $this->makeTask(state: 'completed', outcome: 'approved')); + $this->listener->handle($event); + + }//end testApprovingCompletionDispatchesApproved() + + /** + * A committed completion with a rejecting outcome re-dispatches + * SignerStepRejectedEvent. + * + * @return void + */ + public function testRejectingCompletionDispatchesRejected(): void { + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with( + $this->callback( + function (Event $emitted): bool { + $this->assertInstanceOf(SignerStepRejectedEvent::class, $emitted); + $this->assertSame('akkoord', $emitted->getComment()); + $this->assertSame('alice', $emitted->getUserId()); + + return true; + } + ) + ); + + $event = new TaskTerminalEvent(task: $this->makeTask(state: 'completed', outcome: 'rejected')); + $this->listener->handle($event); + + }//end testRejectingCompletionDispatchesRejected() + + /** + * Every entry of the published rejecting vocabulary classifies as a + * rejection. + * + * @return void + */ + public function testTheWholeRejectingVocabularyClassifiesAsRejection(): void { + foreach (['rejected', 'returned', 'declined', 'denied'] as $outcome) { + $dispatcher = $this->createMock(IEventDispatcher::class); + $dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->isInstanceOf(SignerStepRejectedEvent::class)); + + $listener = new SigningTaskListener( + translator: new SignerEventTranslator( + providerFactory: $this->providerFactory, + dispatcher: $dispatcher, + logger: $this->logger + ), + settingsService: $this->settingsService, + logger: $this->logger + ); + + $listener->handle( + new TaskTerminalEvent(task: $this->makeTask(state: 'completed', outcome: $outcome)) + ); + } + + }//end testTheWholeRejectingVocabularyClassifiesAsRejection() + + /** + * Terminal states that are not completions (cancel, moot, run + * termination) emit nothing: the retired surface had no equivalent. + * + * @return void + */ + public function testTerminatedTaskEmitsNothing(): void { + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + + $event = new TaskTerminalEvent(task: $this->makeTask(state: 'terminated', outcome: 'cancelled')); + $this->listener->handle($event); + + }//end testTerminatedTaskEmitsNothing() + + /** + * A completed sequence anchored on an owned object re-dispatches + * SignerChainCompletedEvent with decider and resolved status. + * + * @return void + */ + public function testSequenceCompletionDispatchesChainCompleted(): void { + $this->dispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with( + $this->callback( + function (Event $emitted): bool { + $this->assertInstanceOf(SignerChainCompletedEvent::class, $emitted); + $this->assertSame('seq-1', $emitted->getSequenceUuid()); + $this->assertSame('task-1', $emitted->getFinalTaskUuid()); + $this->assertSame('bob', $emitted->getUserId()); + $this->assertSame('signed', $emitted->getStatusOnApprove()); + $this->assertSame(self::OWNED_OBJECT, $emitted->getObjectUuid()); + + return true; + } + ) + ); + + $event = new TaskSequenceCompletedEvent( + sequence: new TaskSequence(uuid: 'seq-1', anchorObjectUuid: self::OWNED_OBJECT), + finalTask: $this->makeTask(state: 'completed', outcome: 'approved'), + decider: 'bob', + statusOnApprove: 'signed' + ); + $this->listener->handle($event); + + }//end testSequenceCompletionDispatchesChainCompleted() + + /** + * A completed sequence anchored on a foreign object is ignored. + * + * @return void + */ + public function testForeignSequenceCompletionIsIgnored(): void { + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + + $event = new TaskSequenceCompletedEvent( + sequence: new TaskSequence(uuid: 'seq-9', anchorObjectUuid: 'foreign-object'), + finalTask: $this->makeTask(state: 'completed', outcome: 'approved'), + decider: 'bob', + statusOnApprove: 'signed' + ); + $this->listener->handle($event); + + }//end testForeignSequenceCompletionIsIgnored() + + /** + * Events outside the three task events are ignored without touching any + * collaborator. + * + * @return void + */ + public function testUnrelatedEventIsIgnored(): void { + $this->dispatcher->expects($this->never())->method('dispatchTyped'); + $this->objectService->expects($this->never())->method('find'); + + $this->listener->handle(new class extends Event { + }); + + }//end testUnrelatedEventIsIgnored() + + /** + * A failing downstream dispatch is logged, never rethrown: the listener + * must not break OR's own write path. + * + * @return void + */ + public function testListenerFailureIsLoggedNotThrown(): void { + $this->dispatcher->method('dispatchTyped') + ->willThrowException(new RuntimeException('downstream broke')); + $this->logger->expects($this->once())->method('error'); + + $event = new TaskTerminalEvent(task: $this->makeTask(state: 'completed', outcome: 'approved')); + $this->listener->handle($event); + + // Reaching this line is the assertion: handle() swallowed the failure. + $this->assertTrue(true); + + }//end testListenerFailureIsLoggedNotThrown() +}//end class