diff --git a/lib/Service/ApprovalService.php b/lib/Service/ApprovalService.php index ed24743fb..da532acfa 100644 --- a/lib/Service/ApprovalService.php +++ b/lib/Service/ApprovalService.php @@ -42,6 +42,7 @@ use OCA\Integriq\Service\Helper\FlowToken; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Service\ObjectService as ORObjectService; +use OCA\OpenRegister\Service\Task\TaskService as ORTaskService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\IGroupManager; use OCP\IURLGenerator; @@ -106,6 +107,11 @@ class ApprovalService { * @param ExecutionTraceService|null $executionTraceService Persists the traced run's execution_trace at * suspension/resume (execution-trace REQ-004). Nullable + defaulted so * pre-existing positional test instantiations keep working unmodified. + * @param ORTaskService|null $taskService OpenRegister's shared task service: every suspension mirrors ONE + * shared task through it and every decision closes that mirror + * (hitl-on-shared-tasks D-1). Nullable + defaulted for the same + * positional-test reason; absent, no mirror exists and the approval + * flow is unchanged. */ public function __construct( private readonly ORObjectService $objectService, @@ -115,6 +121,7 @@ public function __construct( private readonly IURLGenerator $urlGenerator, private readonly LoggerInterface $logger, private readonly ?ExecutionTraceService $executionTraceService = null, + private readonly ?ORTaskService $taskService = null, ) { }//end __construct() @@ -195,6 +202,7 @@ public function suspend(ObjectEntity $endpoint, ObjectEntity $rule, FlowToken $f } } + $record = $this->mirrorIntoSharedTask(approvalRequest: $record); $this->notifyApprovers(approvalRequest: $record); return $record; @@ -243,6 +251,7 @@ public function suspendForSynchronization( schema: self::SCHEMA ); + $record = $this->mirrorIntoSharedTask(approvalRequest: $record); $this->notifyApprovers(approvalRequest: $record); return $record; @@ -296,6 +305,7 @@ public function suspendForFlow(ObjectEntity $flowRun, int $resumeStepOrder, arra schema: self::SCHEMA ); + $record = $this->mirrorIntoSharedTask(approvalRequest: $record); $this->notifyApprovers(approvalRequest: $record); return $record; @@ -349,6 +359,7 @@ public function suspendForSubscription( schema: self::SCHEMA ); + $record = $this->mirrorIntoSharedTask(approvalRequest: $record); $this->notifyApprovers(approvalRequest: $record); return $record; @@ -575,13 +586,16 @@ public function completeApproval( $data['comment'] = $comment; } - return $this->objectService->saveObject( + $saved = $this->objectService->saveObject( object: $data, register: self::REGISTER, schema: self::SCHEMA, uuid: $approvalRequest->getUuid() ); + $this->closeSharedTask(data: $data, outcome: 'transition:approved', actorUid: $approver->getUID()); + + return $saved; }//end completeApproval() /** @@ -618,13 +632,24 @@ public function reject(ObjectEntity $approvalRequest, IUser $approver, string $c $data['rejectedAt'] = (new DateTime())->format('c'); $data['comment'] = $comment; - return $this->objectService->saveObject( + $saved = $this->objectService->saveObject( object: $data, register: self::REGISTER, schema: self::SCHEMA, uuid: $approvalRequest->getUuid() ); + // The mirror ends the way the record did: dead-lettered when + // onReject routed the record there, plainly rejected otherwise + // (hitl-on-shared-tasks D-4). + $mirrorOutcome = 'transition:rejected'; + if ($data['status'] === 'dead_letter') { + $mirrorOutcome = 'dead_letter'; + } + + $this->closeSharedTask(data: $data, outcome: $mirrorOutcome, actorUid: $approver->getUID()); + + return $saved; }//end reject() /** @@ -791,6 +816,148 @@ public function notifyApprovers(ObjectEntity $approvalRequest): void { }//end notifyApprovers() + /** + * Mirror a just-created, pending approval_request into ONE shared + * OpenRegister task (hitl-on-shared-tasks D-1/D-2): approver group as + * candidate group, requester, expiry, and the record's + * onTimeout/onReject when they are in the shared vocabulary, so the + * shared timer sweep owns the mirror's expiry (D-3). The created task's + * uuid is written back onto the record as `taskUuid`. + * + * A failure here is logged and swallowed: the approval flow is the + * system of record and MUST NOT be gated by the mirror (D-5). + * + * @param ObjectEntity $approvalRequest The pending approval_request. + * + * @return ObjectEntity The record, carrying `taskUuid` when the mirror was created. + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md#requirement-every-suspension-mirrors-one-shared-task + */ + private function mirrorIntoSharedTask(ObjectEntity $approvalRequest): ObjectEntity { + if ($this->taskService === null) { + return $approvalRequest; + } + + $data = $approvalRequest->getObject(); + $actor = (string)($data['requesterUserId'] ?? ''); + if ($actor === '') { + $actor = 'integriq'; + } + + try { + $task = $this->taskService->import( + data: $this->sharedTaskData(data: $data, approvalRequestId: (string)$approvalRequest->getUuid()), + actor: $actor + ); + + $data['taskUuid'] = (string)$task->getUuid(); + + return $this->objectService->saveObject( + object: $data, + register: self::REGISTER, + schema: self::SCHEMA, + uuid: $approvalRequest->getUuid() + ); + } catch (Throwable $e) { + $this->logger->warning( + 'ApprovalService: could not mirror the approval into the shared task service: ' . $e->getMessage(), + ['approvalRequest' => $approvalRequest->getUuid()] + ); + + return $approvalRequest; + } + }//end mirrorIntoSharedTask() + + /** + * The shared-task payload a pending approval_request mirrors to. + * + * `onTimeout`/`onReject` travel only when they are in the shared + * vocabulary (`skip`|`error`|`dead_letter`); anything else stays an + * app-local behaviour and the mirror carries none. + * + * @param array $data The approval_request object data. + * @param string $approvalRequestId The record uuid the task links back to. + * + * @return array The task creation payload. + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md#requirement-every-suspension-mirrors-one-shared-task + */ + private function sharedTaskData(array $data, string $approvalRequestId): array { + $payload = [ + 'state' => 'enabled', + 'title' => 'Approval request', + 'description' => 'Approve or reject this request in Integriq. Your decision resumes the suspended run.', + 'performerType' => 'user', + 'appId' => 'integriq', + 'metadata' => [ + 'kind' => 'approval_request', + 'approvalRequestId' => $approvalRequestId, + ], + ]; + + if ((string)($data['approverGroup'] ?? '') !== '') { + $payload['candidateGroups'] = [(string)$data['approverGroup']]; + } + + if ((string)($data['requesterUserId'] ?? '') !== '') { + $payload['requester'] = (string)$data['requesterUserId']; + } + + if ((string)($data['expiresAt'] ?? '') !== '') { + $payload['expiresAt'] = (string)$data['expiresAt']; + $onTimeout = (string)($data['onTimeout'] ?? ''); + if (in_array($onTimeout, ['skip', 'error', 'dead_letter'], true) === true) { + $payload['onTimeout'] = $onTimeout; + } + } + + $onReject = (string)($data['onReject'] ?? ''); + if (in_array($onReject, ['skip', 'error', 'dead_letter'], true) === true) { + $payload['onReject'] = $onReject; + } + + return $payload; + }//end sharedTaskData() + + /** + * Close the mirrored shared task after a decision resolved the record + * (hitl-on-shared-tasks D-4), through the shared outcome path: the + * decision was already authorized by this service's own two-layer model, + * and the mirror has no assignee for a completion check to pass. + * + * A missing mirror (`taskUuid` absent: pre-seam rows, or a failed + * mirror) and a mirror already closed by the shared sweep are both + * fine; any failure is logged and swallowed (D-5). + * + * @param array $data The resolved approval_request object data. + * @param string $outcome The shared outcome (`transition:approved`, `transition:rejected` or `dead_letter`). + * @param string $actorUid The deciding user's uid, recorded as the source. + * + * @return void + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md#requirement-a-decision-closes-the-mirrored-task + */ + private function closeSharedTask(array $data, string $outcome, string $actorUid): void { + $taskUuid = (string)($data['taskUuid'] ?? ''); + if ($this->taskService === null || $taskUuid === '') { + return; + } + + try { + $this->taskService->applyTimerOutcome( + uuid: $taskUuid, + outcome: $outcome, + source: 'integriq:' . $actorUid, + reason: sprintf("Approval request resolved as '%s'.", (string)($data['status'] ?? '')) + ); + } catch (Throwable $e) { + $this->logger->warning( + 'ApprovalService: could not close the mirrored shared task: ' . $e->getMessage(), + ['taskUuid' => $taskUuid] + ); + } + }//end closeSharedTask() + /** * Strip sensitive headers (at minimum `Authorization`) from a FlowToken * snapshot's request slots before persisting it — security-hard diff --git a/openspec/changes/hitl-on-shared-tasks/design.md b/openspec/changes/hitl-on-shared-tasks/design.md new file mode 100644 index 000000000..d6853e8f3 --- /dev/null +++ b/openspec/changes/hitl-on-shared-tasks/design.md @@ -0,0 +1,56 @@ +# Design: HITL approvals on the shared task service + +## D-1: a mirror, not a migration + +The approval_request record keeps owning suspend/resume orchestration; the +shared task mirrors its human-facing half (who must act, by when, with what +consequence). Four callers compose resume orchestration around +`ApprovalService` today; moving the record itself is follow-up work with its +own change. The mirror gives the fleet inbox, notification and expiry +machinery a real row NOW without touching any resume path. + +## D-2: the mirror is created on the trusted path + +`TaskService::import()` (in-process, trusted) rather than `create()`: the +mirror names a requester that is not the acting identity, and it is created +by a service, not over HTTP. The acting identity passed is the requester +when known, else the app id. + +## D-3: OpenRegister owns the mirror's expiry + +The mirrored task carries `expiresAt` and the record's `onTimeout` (when it +is one of `skip`/`error`/`dead_letter`), so the shared timer sweep closes it +with the declared behaviour. integriq's `ApprovalTimeoutSweepJob` keeps +resolving the approval_request itself. The two sweeps run at the same 300s +cadence and both are idempotent, so the pair converges without coordination: +the record ends `expired`/`dead_letter`, the task ends through its declared +behaviour. Retiring the app-local sweep for mirrored rows is follow-up 1. + +## D-4: decisions close the mirror through the outcome path + +`completeApproval()` closes the mirror with `transition:approved`; +`reject()` with `transition:rejected`, or `dead_letter` when the record's +`onReject` routed the record there. The outcome path +(`applyTimerOutcome()`) is chosen over `complete()` because the mirror has +no assignee: the decision was authorized by integriq's own two-layer model +(action matrix + approver group) before the close, and re-running the task +service's assignee check against a pooled mirror would refuse a decision +that already happened. The source names the deciding user +(`integriq:`). + +## D-5: the mirror never gates the approval flow + +Creation, linking and closing of the mirror are each wrapped: a failure is +logged as a warning and the approval flow proceeds. A missing `taskUuid` +(pre-seam rows, or a failed mirror) simply means no mirror to close. The +inverse guarantee is OpenRegister's: `applyTimerOutcome()` on an +already-terminal task returns it unchanged, so a decision racing the shared +sweep cannot double-close. + +## D-6: tests stub the real signatures + +integriq's suite runs without the OpenRegister app. The stubs added for +`OCA\OpenRegister\Service\Task\TaskService` and `OCA\OpenRegister\Db\Task` +copy the REAL signatures (`import(array $data, ?string $actor): Task`, +`applyTimerOutcome(string $uuid, string $outcome, string $source, string +$reason): Task`), because a fake that agrees with the caller cannot fail. diff --git a/openspec/changes/hitl-on-shared-tasks/proposal.md b/openspec/changes/hitl-on-shared-tasks/proposal.md new file mode 100644 index 000000000..32e36b47e --- /dev/null +++ b/openspec/changes/hitl-on-shared-tasks/proposal.md @@ -0,0 +1,55 @@ +# HITL approvals on the shared task service + +## Why + +integriq carries its own human-in-the-loop machinery: `approval_request` +objects with `expiresAt`, `onTimeout` and `onReject`, a 300s +`ApprovalTimeoutSweepJob`, and an imperative approver notification. The +fleet now has one task service in OpenRegister, and wave 2 of the +consolidation moved exactly these semantics into it +(openregister `task-expiry-and-outcomes`): tasks declare `onTimeout` and +`onReject` in the same vocabulary, and the shared sweep enforces `expiresAt`. +Keeping a second copy here means two sweeps, two vocabularies and an +approval inbox nobody shares. + +## What changes + +This change is the ADOPTION SEAM, not the full retirement. The +`approval_request` record stays the system of record for suspend/resume +orchestration (FlowToken snapshots, resume ordering, consumption), because +that orchestration is composed by `EndpointService`, `FlowRunnerService`, +`SynchronizationService` and two controllers, and ripping it out in the same +PR that introduces the seam would be a half-delete. + +- Every `ApprovalService::suspend*()` also creates ONE shared task through + OpenRegister's `TaskService::import()`: candidate group, requester, + `expiresAt`, `onTimeout`, `onReject` and a metadata link to the + approval_request. The task uuid lands on the approval_request as + `taskUuid`. +- Expiry of the mirrored task is OWNED by OpenRegister's timer sweep: the + task declares `onTimeout`, so the shared machinery closes it. integriq's + own sweep keeps resolving the `approval_request` record. +- `completeApproval()` and `reject()` close the mirrored task with the + matching outcome (`approved`, `rejected`, or `dead_letter` when the + record's `onReject` said so), so the shared inbox never shows a decided + approval as open. +- A mirror failure never fails the approval flow: created, closed or + skipped, the approval_request behaviour is unchanged. + +## Follow-ups (tracked, not in this PR) + +1. Listen to OpenRegister's `TaskTransitionedEvent` for mirrored tasks and + resolve the approval_request from the task side, then retire + `ApprovalTimeoutSweepJob` for mirrored rows. +2. Drive approve/reject from the shared task inbox (task-first), reducing + `ApprovalsController` to the resume orchestration. +3. Translate the mirrored task's title and description. + +## Impact + +- Affected specs: hitl-on-shared-tasks (new delta), referencing + approval-workflow. +- Affected code: `lib/Service/ApprovalService.php`, test stubs for the + OpenRegister task service. +- Depends on: openregister `task-expiry-and-outcomes` (runtime only; tests + stub the shared service). diff --git a/openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md b/openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md new file mode 100644 index 000000000..2f52fd4b8 --- /dev/null +++ b/openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md @@ -0,0 +1,61 @@ +# hitl-on-shared-tasks + +## ADDED Requirements + +### Requirement: Every suspension mirrors one shared task + +Each `approval_request` created by a suspension SHALL be mirrored by exactly +one OpenRegister task, created through the shared task service's trusted +path, carrying the approver group as candidate group, the requester, the +`expiresAt`, and the record's `onTimeout` and `onReject` when they are in +the shared vocabulary. The task uuid SHALL be stored on the approval_request +as `taskUuid`. A mirror failure SHALL NOT fail the suspension. + +#### Scenario: a suspension creates the linked mirror task + +- **GIVEN** an endpoint rule pipeline suspending on an approval rule +- **WHEN** the approval_request is persisted +- **THEN** a shared task is created with the approver group, expiry and behaviours, and the record carries its uuid +- @e2e exclude {cross-app persistence seam; covered by unit tests against the stubbed shared service} + +#### Scenario: a failing shared service does not block the suspension + +- **GIVEN** a shared task service that throws on import +- **WHEN** the pipeline suspends +- **THEN** the approval_request is created and pending, without a `taskUuid`, and the failure is logged +- @e2e exclude {fault injection on a peer app; covered by unit tests} + +### Requirement: A decision closes the mirrored task + +Approving SHALL close the mirrored task with the `approved` outcome; +rejecting SHALL close it with the `rejected` outcome, or the dead-letter +outcome when the record's `onReject` routed the record to `dead_letter`. A +missing or already-closed mirror SHALL NOT fail the decision. + +#### Scenario: an approval closes the mirror as approved + +- **GIVEN** a pending approval_request carrying a `taskUuid` +- **WHEN** an authorized approver approves it +- **THEN** the mirrored task is closed with outcome `approved`, attributed to the deciding user +- @e2e exclude {cross-app close seam; covered by unit tests against the stubbed shared service} + +#### Scenario: a dead-letter rejection routes the mirror the same way + +- **GIVEN** a pending approval_request with `onReject: dead_letter` and a `taskUuid` +- **WHEN** an authorized approver rejects it with a comment +- **THEN** the record and the mirrored task both end dead-lettered +- @e2e exclude {cross-app close seam; covered by unit tests} + +### Requirement: The shared sweep owns the mirror's expiry + +The mirrored task SHALL declare its expiry behaviour so OpenRegister's timer +sweep encloses it; integriq's own sweep SHALL keep resolving the +approval_request record and SHALL NOT gain a second enforcement path for the +mirror. + +#### Scenario: an expired approval converges on both sides + +- **GIVEN** a pending approval_request past its `expiresAt`, mirrored with `onTimeout` +- **WHEN** both 300s sweeps have run +- **THEN** the record is `expired` (or `dead_letter`) and the task was closed by the shared sweep with the declared behaviour +- @e2e exclude {two background sweeps across apps; each side is covered by its own unit tests} diff --git a/openspec/changes/hitl-on-shared-tasks/tasks.md b/openspec/changes/hitl-on-shared-tasks/tasks.md new file mode 100644 index 000000000..f824636c3 --- /dev/null +++ b/openspec/changes/hitl-on-shared-tasks/tasks.md @@ -0,0 +1,25 @@ +# Tasks: hitl-on-shared-tasks + +## 1. The seam (this PR) + +- [x] 1.1 `ApprovalService` gains the nullable shared task service and a + `mirrorIntoSharedTask()` called from all four suspend paths, linking + `taskUuid` onto the approval_request; failures logged, never thrown. +- [x] 1.2 `completeApproval()`/`reject()` close the mirror with the + matching outcome through the shared outcome path. +- [x] 1.3 Test stubs for `OCA\OpenRegister\Service\Task\TaskService` and + `OCA\OpenRegister\Db\Task` with the real signatures, registered in + the bootstrap. +- [x] 1.4 Unit tests: mirror created and linked, decision closes it, + failures never gate the approval flow. + +## 2. Follow-ups (tracked in the issue, NOT this PR) + +- [ ] 2.1 Listen to `TaskTransitionedEvent` for mirrored tasks; resolve the + approval_request task-first; retire `ApprovalTimeoutSweepJob` for + mirrored rows (keep it for pre-seam rows). +- [ ] 2.2 Drive approve/reject from the shared inbox; reduce + `ApprovalsController` to resume orchestration. +- [ ] 2.3 Delegate the approver notification to the shared task service and + drop the imperative dispatch in `notifyApprovers()`. +- [ ] 2.4 Translate the mirrored task's title and description. diff --git a/psalm.xml b/psalm.xml index 05a90ed54..0e66e022b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -77,6 +77,11 @@ + + + diff --git a/tests/Unit/Service/ApprovalServiceSharedTaskTest.php b/tests/Unit/Service/ApprovalServiceSharedTaskTest.php new file mode 100644 index 000000000..f07d2b71c --- /dev/null +++ b/tests/Unit/Service/ApprovalServiceSharedTaskTest.php @@ -0,0 +1,355 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Tests\Unit\Service; + +use OCA\Integriq\Service\ApprovalService; +use OCA\Integriq\Service\Helper\FlowToken; +use OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder; +use OCA\OpenRegister\Db\ObjectEntity; +use OCA\OpenRegister\Db\Task; +use OCA\OpenRegister\Service\Task\TaskService as ORTaskService; +use OCP\IGroupManager; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Notification\IManager as INotificationManager; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Tests for the shared-task mirror seam. + * + * @spec openspec/changes/hitl-on-shared-tasks/specs/hitl-on-shared-tasks/spec.md + */ +class ApprovalServiceSharedTaskTest extends TestCase { + + /** + * @var \PHPUnit\Framework\MockObject\MockObject + */ + private $objectService; + + /** + * @var ORTaskService|MockObject + */ + private $taskService; + + /** + * @var LoggerInterface|MockObject + */ + private $logger; + + /** + * @var ApprovalService + */ + private ApprovalService $service; + + /** + * Every saveObject call's payload, in order. + * + * @var array + */ + private array $saved = []; + + /** + * Set up fixtures. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->objectService = ObjectServiceMockBuilder::make($this); + $this->taskService = $this->createMock(ORTaskService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->saved = []; + $this->objectService->method('saveObject')->willReturnCallback( + function (array $object) { + $this->saved[] = $object; + $entity = new ObjectEntity(); + $entity->setUuid('approval-created'); + $entity->setObject($object); + + return $entity; + } + ); + + $userSession = $this->createMock(IUserSession::class); + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('rita'); + $userSession->method('getUser')->willReturn($user); + + $this->service = new ApprovalService( + $this->objectService, + $userSession, + $this->createMock(IGroupManager::class), + $this->createMock(INotificationManager::class), + $this->createMock(IURLGenerator::class), + $this->logger, + null, + $this->taskService, + ); + + }//end setUp() + + /** + * A shared task entity carrying a uuid, via the real Entity accessors. + * + * @param string $uuid The task uuid. + * + * @return Task + */ + private function task(string $uuid): Task { + $task = new Task(); + $task->setUuid($uuid); + + return $task; + }//end task() + + /** + * A resolved-enough approval_request entity. + * + * @param array $body The object data. + * + * @return ObjectEntity + */ + private function entity(array $body): ObjectEntity { + $entity = new ObjectEntity(); + $entity->setUuid('approval-1'); + $entity->setObject($body); + + return $entity; + }//end entity() + + /** + * The deciding user. + * + * @return IUser|MockObject + */ + private function approver() { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + + return $user; + }//end approver() + + /** + * A suspension creates ONE shared task through the trusted path, + * carrying group, expiry, behaviours and the record link, and writes + * the task uuid back onto the record. + * + * @return void + */ + public function testSuspendForSynchronizationMirrorsOneLinkedSharedTask(): void { + $imported = null; + $this->taskService->expects($this->once())->method('import')->willReturnCallback( + function (array $data, ?string $actor) use (&$imported): Task { + $imported = ['data' => $data, 'actor' => $actor]; + + return $this->task('task-9'); + } + ); + + $this->service->suspendForSynchronization( + synchronizationId: 'sync-1', + approverGroup: 'woo-approvers', + onReject: 'error', + onTimeout: 'dead_letter', + ttlSeconds: 3600, + ); + + $this->assertSame(['woo-approvers'], $imported['data']['candidateGroups']); + $this->assertSame('dead_letter', $imported['data']['onTimeout']); + $this->assertSame('error', $imported['data']['onReject']); + $this->assertNotEmpty($imported['data']['expiresAt']); + $this->assertSame('rita', $imported['data']['requester']); + $this->assertSame('rita', $imported['actor']); + $this->assertSame('approval-created', $imported['data']['metadata']['approvalRequestId']); + $this->assertSame('integriq', $imported['data']['appId']); + + // Two record writes: the pending create, then the taskUuid link. + $this->assertCount(2, $this->saved); + $this->assertArrayNotHasKey('taskUuid', $this->saved[0]); + $this->assertSame('task-9', $this->saved[1]['taskUuid']); + + }//end testSuspendForSynchronizationMirrorsOneLinkedSharedTask() + + /** + * A behaviour outside the shared vocabulary is NOT forwarded: the + * mirror carries no behaviour rather than a refused word. + * + * @return void + */ + public function testAnUnknownBehaviourStaysAppLocal(): void { + $imported = null; + $this->taskService->method('import')->willReturnCallback( + function (array $data, ?string $actor) use (&$imported): Task { + $imported = $data; + + return $this->task('task-9'); + } + ); + + $this->service->suspendForSynchronization( + synchronizationId: 'sync-1', + approverGroup: 'woo-approvers', + onReject: 'explode', + onTimeout: 'explode', + ttlSeconds: 60, + ); + + $this->assertArrayNotHasKey('onTimeout', $imported); + $this->assertArrayNotHasKey('onReject', $imported); + + }//end testAnUnknownBehaviourStaysAppLocal() + + /** + * A failing shared service never fails the suspension: the record is + * created pending, without a taskUuid, and the failure is logged. + * + * @return void + */ + public function testAMirrorFailureNeverGatesTheSuspension(): void { + $this->taskService->method('import')->willThrowException(new RuntimeException('peer app down')); + $warnings = []; + $this->logger->method('warning')->willReturnCallback( + static function (string $message) use (&$warnings): void { + $warnings[] = $message; + } + ); + + $record = $this->service->suspendForSynchronization( + synchronizationId: 'sync-1', + approverGroup: 'woo-approvers', + onReject: 'error', + onTimeout: 'error', + ttlSeconds: 60, + ); + + $this->assertSame('pending', $record->getObject()['status']); + $this->assertCount(1, $this->saved, 'no link write happened'); + $this->assertArrayNotHasKey('taskUuid', $this->saved[0]); + $this->assertNotEmpty(array_filter($warnings, static fn (string $m): bool => str_contains($m, 'could not mirror'))); + + }//end testAMirrorFailureNeverGatesTheSuspension() + + /** + * An approval closes the mirror as approved, attributed to the + * deciding user. + * + * @return void + */ + public function testAnApprovalClosesTheMirrorAsApproved(): void { + $this->taskService->expects($this->once())->method('applyTimerOutcome') + ->with( + $this->equalTo('task-9'), + $this->equalTo('transition:approved'), + $this->equalTo('integriq:alice'), + $this->stringContains('approved') + ) + ->willReturn($this->task('task-9')); + + $this->service->completeApproval( + approvalRequest: $this->entity(['status' => 'pending', 'taskUuid' => 'task-9']), + approver: $this->approver(), + resumeResult: 'success', + ); + + }//end testAnApprovalClosesTheMirrorAsApproved() + + /** + * A dead-letter rejection routes the mirror the same way the record + * went. + * + * @return void + */ + public function testADeadLetterRejectionDeadLettersTheMirror(): void { + $this->taskService->expects($this->once())->method('applyTimerOutcome') + ->with( + $this->equalTo('task-9'), + $this->equalTo('dead_letter'), + $this->equalTo('integriq:alice'), + $this->anything() + ) + ->willReturn($this->task('task-9')); + + $this->service->reject( + approvalRequest: $this->entity(['status' => 'pending', 'onReject' => 'dead_letter', 'taskUuid' => 'task-9']), + approver: $this->approver(), + comment: 'niet akkoord', + ); + + }//end testADeadLetterRejectionDeadLettersTheMirror() + + /** + * A plain rejection closes the mirror as rejected. + * + * @return void + */ + public function testAPlainRejectionClosesTheMirrorAsRejected(): void { + $this->taskService->expects($this->once())->method('applyTimerOutcome') + ->with( + $this->equalTo('task-9'), + $this->equalTo('transition:rejected'), + $this->anything(), + $this->anything() + ) + ->willReturn($this->task('task-9')); + + $this->service->reject( + approvalRequest: $this->entity(['status' => 'pending', 'onReject' => 'error', 'taskUuid' => 'task-9']), + approver: $this->approver(), + comment: 'nee', + ); + + }//end testAPlainRejectionClosesTheMirrorAsRejected() + + /** + * A record without a mirror (pre-seam, or a failed mirror) decides + * without touching the shared service, and a failing close is + * swallowed. + * + * @return void + */ + public function testAMissingOrFailingMirrorNeverGatesTheDecision(): void { + $this->taskService->expects($this->never())->method('applyTimerOutcome'); + $this->service->completeApproval( + approvalRequest: $this->entity(['status' => 'pending']), + approver: $this->approver(), + resumeResult: 'success', + ); + + $this->setUp(); + $this->taskService->method('applyTimerOutcome')->willThrowException(new RuntimeException('gone')); + $this->logger->expects($this->once())->method('warning'); + $saved = $this->service->completeApproval( + approvalRequest: $this->entity(['status' => 'pending', 'taskUuid' => 'task-9']), + approver: $this->approver(), + resumeResult: 'success', + ); + $this->assertSame('approved', $saved->getObject()['status']); + + }//end testAMissingOrFailingMirrorNeverGatesTheDecision() +}//end class diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8c962d0b2..6b9756abe 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -122,6 +122,17 @@ require_once $stubsDir . '/OCA/OpenRegister/Db/ObjectEntity.php'; } + // The shared task entity + service the HITL mirror writes through + // (hitl-on-shared-tasks). The entity must load before the service: + // the service's signatures reference it. + if (class_exists('OCA\\OpenRegister\\Db\\Task') === false) { + require_once $stubsDir . '/OCA/OpenRegister/Db/Task.php'; + } + + if (class_exists('OCA\\OpenRegister\\Service\\Task\\TaskService') === false) { + require_once $stubsDir . '/OCA/OpenRegister/Service/Task/TaskService.php'; + } + if (class_exists('OCA\\OpenRegister\\Service\\ObjectService') === false) { require_once $stubsDir . '/OCA/OpenRegister/Service/ObjectService.php'; } diff --git a/tests/stubs/OCA/OpenRegister/Db/Task.php b/tests/stubs/OCA/OpenRegister/Db/Task.php new file mode 100644 index 000000000..610d7810f --- /dev/null +++ b/tests/stubs/OCA/OpenRegister/Db/Task.php @@ -0,0 +1,56 @@ + $data The task fields. + * @param string|null $actor The creating identity. + * + * @return Task An empty task entity. + */ + public function import(array $data, ?string $actor): Task { + return new Task(); + }//end import() + + /** + * Apply a declared outcome to a task (real: idempotent on terminal rows). + * + * @param string $uuid The task uuid. + * @param string $outcome The declared outcome. + * @param string $source The applying source. + * @param string $reason The audited reason. + * + * @return Task An empty task entity. + */ + public function applyTimerOutcome(string $uuid, string $outcome, string $source, string $reason): Task { + return new Task(); + }//end applyTimerOutcome() +}