diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1f8c71612..f5fcb1a28 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -42,7 +42,9 @@ use OCA\Integriq\Capabilities; use OCA\Integriq\Controller\HealthController; use OCA\Integriq\Controller\MetricsController; +use OCA\Integriq\Event\DeliveryRequestedEvent; use OCA\Integriq\EventListener\CloudEventListener; +use OCA\Integriq\EventListener\DeliveryRequestedListener; use OCA\Integriq\EventListener\EndpointCacheInvalidationListener; use OCA\Integriq\EventListener\NextcloudCalendarEventListener; use OCA\Integriq\EventListener\NextcloudFileEventListener; @@ -199,6 +201,11 @@ function ($c) { $dispatcher->addServiceListener(eventName: ObjectCreatedEvent::class, className: CloudEventListener::class); $dispatcher->addServiceListener(eventName: ObjectUpdatedEvent::class, className: CloudEventListener::class); $dispatcher->addServiceListener(eventName: ObjectDeletedEvent::class, className: CloudEventListener::class); + // ADR-041 cross-app delivery seam: a sibling app (dossiq, ...) raises + // a typed DeliveryRequestedEvent; this listener ingests it into the + // same CloudEvents pipeline (subscription routing, retry, dead-letter, + // replay) and writes the synchronous result slot back on the event. + $dispatcher->addServiceListener(eventName: DeliveryRequestedEvent::class, className: DeliveryRequestedListener::class); // Nextcloud-core-event triggers (nextcloud-event-hub). Each family // normalizes its NC event into the SAME `event` CloudEvents envelope // shape the OR-object pipeline above already uses, then hands off to diff --git a/lib/Event/DeliveryConcludedEvent.php b/lib/Event/DeliveryConcludedEvent.php new file mode 100644 index 000000000..545ce7da1 --- /dev/null +++ b/lib/Event/DeliveryConcludedEvent.php @@ -0,0 +1,198 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Event; + +use OCP\EventDispatcher\Event; + +/** + * Terminal outcome of a cross-app delivery request. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) -- the ADR-041 event contract is a flat + * readonly provenance envelope (sourceApp, subject coordinates, kind/channel, correlation); + * folding fields into an array would untype the contract the consumer stubs must mirror + * verbatim. Mirrors the decidiq DecisionRequestedEvent precedent. + */ +class DeliveryConcludedEvent extends Event { + /** + * Terminal status: the delivery succeeded. + */ + public const STATUS_DELIVERED = 'delivered'; + + /** + * Terminal status: the retry budget is spent, no further attempts. + */ + public const STATUS_ABANDONED = 'abandoned'; + + /** + * Constructor. + * + * @param string $sourceApp The app that raised the original request. + * @param string $correlationId The caller's correlation id, echoed verbatim. + * @param string $subjectId The subject object id from the original request. + * @param string $channel The delivery channel from the original request. + * @param string $status Terminal status: {@see self::STATUS_DELIVERED} or {@see self::STATUS_ABANDONED}. + * @param string $eventId Uuid of the CloudEvent `event` object. + * @param string $messageId Uuid of the `event_message` delivery record. + * @param int $attempts How many delivery attempts were made. + * @param string|null $error The last delivery error, or null on success. + * @param string $concludedAt ISO 8601 timestamp of the terminal transition. + * + * @return void + */ + public function __construct( + private readonly string $sourceApp, + private readonly string $correlationId, + private readonly string $subjectId, + private readonly string $channel, + private readonly string $status, + private readonly string $eventId, + private readonly string $messageId, + private readonly int $attempts, + private readonly ?string $error, + private readonly string $concludedAt, + ) { + parent::__construct(); + }//end __construct() + + /** + * The app that raised the original request. + * + * @return string The source app id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSourceApp(): string { + return $this->sourceApp; + }//end getSourceApp() + + /** + * The caller's correlation id. + * + * @return string The correlation id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getCorrelationId(): string { + return $this->correlationId; + }//end getCorrelationId() + + /** + * The subject object id from the original request. + * + * @return string The subject id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectId(): string { + return $this->subjectId; + }//end getSubjectId() + + /** + * The delivery channel from the original request. + * + * @return string The channel. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getChannel(): string { + return $this->channel; + }//end getChannel() + + /** + * Terminal status of the delivery. + * + * @return string One of the STATUS_* constants. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getStatus(): string { + return $this->status; + }//end getStatus() + + /** + * Uuid of the CloudEvent `event` object. + * + * @return string The event uuid. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getEventId(): string { + return $this->eventId; + }//end getEventId() + + /** + * Uuid of the `event_message` delivery record. + * + * @return string The message uuid. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getMessageId(): string { + return $this->messageId; + }//end getMessageId() + + /** + * How many delivery attempts were made. + * + * @return int The attempt count. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getAttempts(): int { + return $this->attempts; + }//end getAttempts() + + /** + * The last delivery error. + * + * @return string|null The error, or null on success. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getError(): ?string { + return $this->error; + }//end getError() + + /** + * When the delivery reached its terminal state. + * + * @return string ISO 8601 timestamp. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getConcludedAt(): string { + return $this->concludedAt; + }//end getConcludedAt() +}//end class diff --git a/lib/Event/DeliveryRequestedEvent.php b/lib/Event/DeliveryRequestedEvent.php new file mode 100644 index 000000000..be051530c --- /dev/null +++ b/lib/Event/DeliveryRequestedEvent.php @@ -0,0 +1,301 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Event; + +use OCP\EventDispatcher\Event; + +/** + * Typed cross-app command: "deliver this payload on my behalf". + * + * Carries provenance (which app, which subject object), a delivery payload + * reference, and a synchronous result slot the in-process listener writes: + * `isHandled()` + `getResultId()` (the persisted CloudEvent uuid) + + * `getMatchedSubscriptions()` (how many delivery routes picked it up — zero + * means the request was accepted but nothing is configured to deliver it, + * which a fail-closed consumer records as a refusal, not a success). + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + * + * @SuppressWarnings(PHPMD.ExcessiveParameterList) -- the ADR-041 event contract is a flat + * readonly provenance envelope (sourceApp, subject coordinates, kind/channel, correlation); + * folding fields into an array would untype the contract the consumer stubs must mirror + * verbatim. Mirrors the decidiq DecisionRequestedEvent precedent. + */ +class DeliveryRequestedEvent extends Event { + /** + * Whether an Integriq listener handled the request. + * + * @var bool + */ + private bool $handled = false; + + /** + * Uuid of the persisted CloudEvent `event` object, once handled. + * + * @var string|null + */ + private ?string $resultId = null; + + /** + * How many active event subscriptions matched the delivery request. + * + * @var int + */ + private int $matchedSubscriptions = 0; + + /** + * Constructor. + * + * @param string $sourceApp The requesting app id (e.g. `dossiq`). + * @param string $subjectRegister The OpenRegister register slug/id of the subject object. + * @param string $subjectSchema The schema slug/id of the subject object. + * @param string $subjectId The subject object id/uuid (e.g. the case id). + * @param string $subjectLabel Human-readable label for the subject. + * @param string $deliveryKind What is being delivered (e.g. `besluit-publication`). + * @param string $channel The requested delivery channel (e.g. `gemeenteblad`). + * @param array $payload The delivery payload reference (composed by the source app). + * @param string $correlationId Caller-generated id echoed on the concluded event. + * @param string|null $externalReference Optional external reference (e.g. besluit identificatie). + * @param string|null $userId The acting Nextcloud user, or null for system-produced requests. + * + * @return void + */ + public function __construct( + private readonly string $sourceApp, + private readonly string $subjectRegister, + private readonly string $subjectSchema, + private readonly string $subjectId, + private readonly string $subjectLabel, + private readonly string $deliveryKind, + private readonly string $channel, + private readonly array $payload, + private readonly string $correlationId, + private readonly ?string $externalReference = null, + private readonly ?string $userId = null, + ) { + parent::__construct(); + }//end __construct() + + /** + * The requesting app id. + * + * @return string The source app id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSourceApp(): string { + return $this->sourceApp; + }//end getSourceApp() + + /** + * The subject object's register. + * + * @return string The register slug/id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectRegister(): string { + return $this->subjectRegister; + }//end getSubjectRegister() + + /** + * The subject object's schema. + * + * @return string The schema slug/id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectSchema(): string { + return $this->subjectSchema; + }//end getSubjectSchema() + + /** + * The subject object id. + * + * @return string The object id/uuid. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectId(): string { + return $this->subjectId; + }//end getSubjectId() + + /** + * Human-readable subject label. + * + * @return string The label. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getSubjectLabel(): string { + return $this->subjectLabel; + }//end getSubjectLabel() + + /** + * What is being delivered. + * + * @return string The delivery kind. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getDeliveryKind(): string { + return $this->deliveryKind; + }//end getDeliveryKind() + + /** + * The requested delivery channel. + * + * @return string The channel. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getChannel(): string { + return $this->channel; + }//end getChannel() + + /** + * The delivery payload reference. + * + * @return array The payload. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getPayload(): array { + return $this->payload; + }//end getPayload() + + /** + * The caller's correlation id. + * + * @return string The correlation id. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getCorrelationId(): string { + return $this->correlationId; + }//end getCorrelationId() + + /** + * Optional external reference. + * + * @return string|null The external reference. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getExternalReference(): ?string { + return $this->externalReference; + }//end getExternalReference() + + /** + * The acting Nextcloud user. + * + * @return string|null The user id, or null for system-produced requests. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getUserId(): ?string { + return $this->userId; + }//end getUserId() + + /** + * Mark the request as handled by an Integriq listener. + * + * @param bool $handled Whether the request was handled. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function setHandled(bool $handled): void { + $this->handled = $handled; + }//end setHandled() + + /** + * Whether an Integriq listener handled the request. + * + * @return bool True when handled. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function isHandled(): bool { + return $this->handled; + }//end isHandled() + + /** + * Record the persisted CloudEvent uuid. + * + * @param string $resultId The event object uuid. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function setResultId(string $resultId): void { + $this->resultId = $resultId; + }//end setResultId() + + /** + * The persisted CloudEvent uuid, once handled. + * + * @return string|null The event object uuid. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getResultId(): ?string { + return $this->resultId; + }//end getResultId() + + /** + * Record how many subscriptions matched. + * + * @param int $matchedSubscriptions The matched subscription count. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function setMatchedSubscriptions(int $matchedSubscriptions): void { + $this->matchedSubscriptions = $matchedSubscriptions; + }//end setMatchedSubscriptions() + + /** + * How many active subscriptions matched the delivery request. + * + * @return int The matched subscription count. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function getMatchedSubscriptions(): int { + return $this->matchedSubscriptions; + }//end getMatchedSubscriptions() +}//end class diff --git a/lib/EventListener/DeliveryRequestedListener.php b/lib/EventListener/DeliveryRequestedListener.php new file mode 100644 index 000000000..d9b05c26b --- /dev/null +++ b/lib/EventListener/DeliveryRequestedListener.php @@ -0,0 +1,100 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @version GIT: + * + * @link https://conduction.nl + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Integriq\EventListener; + +use OCA\Integriq\Event\DeliveryRequestedEvent; +use OCA\Integriq\Service\EventService; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use Psr\Log\LoggerInterface; + +/** + * Listener that ingests cross-app delivery requests into the CloudEvents + * pipeline. + * + * On success it marks the event handled, records the persisted CloudEvent + * uuid as the result id, and reports the matched-subscription count so a + * fail-closed consumer can distinguish "accepted and routed" from "accepted + * but no delivery route is configured". On ingest failure the event stays + * unhandled — the consumer's fail-closed guard then records a refusal. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ +class DeliveryRequestedListener implements IEventListener { + /** + * Constructor. + * + * @param EventService $eventService The CloudEvents pipeline entry point. + * @param LoggerInterface $logger Logger for ingest failures. + * + * @return void + */ + public function __construct( + private readonly EventService $eventService, + private readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Handle a cross-app delivery request. + * + * @param Event $event The dispatched event. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function handle(Event $event): void { + if (($event instanceof DeliveryRequestedEvent) === false) { + return; + } + + try { + $result = $this->eventService->ingestDeliveryRequest(request: $event); + } catch (\Throwable $e) { + // Leave the event unhandled: the consumer's fail-closed guard + // records the refusal on its own domain record. + $this->logger->error( + 'Delivery request ingest failed: ' . $e->getMessage(), + [ + 'exception' => $e, + 'sourceApp' => $event->getSourceApp(), + 'correlationId' => $event->getCorrelationId(), + ] + ); + return; + }//end try + + $event->setResultId(resultId: (string)$result['event']->getUuid()); + $event->setMatchedSubscriptions(matchedSubscriptions: count($result['messages'])); + $event->setHandled(handled: true); + }//end handle() +}//end class diff --git a/lib/Service/EventService.php b/lib/Service/EventService.php index 9985c7bcc..b8296833e 100644 --- a/lib/Service/EventService.php +++ b/lib/Service/EventService.php @@ -22,6 +22,8 @@ use DateTime; use Exception; use JWadhams\JsonLogic; +use OCA\Integriq\Event\DeliveryConcludedEvent; +use OCA\Integriq\Event\DeliveryRequestedEvent; use OCA\Integriq\Exception\FormsFeatureDisabledException; use OCA\Integriq\Exception\InvalidMessageStateException; use OCA\Integriq\Service\Forms\FormsAnswerResolver; @@ -30,6 +32,7 @@ use OCA\Integriq\Service\Security\SensitiveFieldRegistry; use OCA\OpenRegister\Db\ObjectEntity; use OCA\OpenRegister\Service\ObjectService as ORObjectService; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Http\Client\IClientService; use Psr\Log\LoggerInterface; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; @@ -116,6 +119,17 @@ class EventService { */ public const NEXTCLOUD_SOURCE_PREFIX = '/nextcloud/'; + /** + * CloudEvents `type` for cross-app delivery requests ingested through the + * ADR-041 typed-event seam ({@see DeliveryRequestedEvent}). Subscriptions + * route on this type plus `data.delivery.*` provenance filters. + * + * @var string + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public const DELIVERY_REQUESTED_TYPE = 'nl.conduction.delivery.requested'; + /** * Constructor. * @@ -143,6 +157,10 @@ class EventService { * so pre-existing positional test * instantiations keep working * unmodified. + * @param IEventDispatcher|null $eventDispatcher Dispatches {@see DeliveryConcludedEvent} when a + * provenance-carrying delivery reaches a terminal + * state (ADR-041 seam). Nullable + defaulted for + * the same test-compatibility reason as above. * * @spec openspec/specs/events-cloudevents/spec.md#requirement-a-subscription-s-action-dispatch-must-support-webhook-synchronization-or-job-kinds-req-008 * @spec openspec/specs/events-cloudevents/spec.md#requirement-a-subscription-s-action-dispatch-must-support-a-notificaties-kind-for-zgw-notificaties-api-publishing-req-010 @@ -162,6 +180,7 @@ public function __construct( private readonly ?FormsAnswerResolver $formsAnswerResolver = null, private readonly ?FormsSyncAdapter $formsSyncAdapter = null, private readonly ?ExecutionTraceService $executionTraceService = null, + private readonly ?IEventDispatcher $eventDispatcher = null, ) { }//end __construct() @@ -787,6 +806,15 @@ private function recordFailure( uuid: $message->getUuid() ); + if ($messageData['status'] === 'abandoned') { + $this->dispatchDeliveryConcluded( + message: $message, + messageData: $messageData, + status: DeliveryConcludedEvent::STATUS_ABANDONED, + concludedAt: $nowIso + ); + } + }//end recordFailure() /** @@ -1819,8 +1847,88 @@ private function recordDeliverySuccess(ObjectEntity $message): void { uuid: $message->getUuid() ); + $this->dispatchDeliveryConcluded( + message: $message, + messageData: $messageData, + status: DeliveryConcludedEvent::STATUS_DELIVERED, + concludedAt: $now + ); + }//end recordDeliverySuccess() + /** + * Dispatch the terminal {@see DeliveryConcludedEvent} for a + * provenance-carrying delivery message (ADR-041 seam). + * + * Gated to messages whose originating event was ingested through + * {@see ingestDeliveryRequest} — the `data.delivery.sourceApp` + + * `correlationId` provenance block is the gate, so ordinary CloudEvent + * traffic never produces a concluded event. Dispatch failures are logged + * and swallowed: the message's own status record is the source of truth + * and must not be rolled back by a consumer-side listener error. + * + * @param ObjectEntity $message The event_message row that reached a terminal state. + * @param array $messageData The message's persisted data (post-transition). + * @param string $status Terminal status: {@see DeliveryConcludedEvent::STATUS_DELIVERED} + * or {@see DeliveryConcludedEvent::STATUS_ABANDONED}. + * @param string $concludedAt ISO 8601 timestamp of the terminal transition. + * + * @return void + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + private function dispatchDeliveryConcluded( + ObjectEntity $message, + array $messageData, + string $status, + string $concludedAt, + ): void { + if ($this->eventDispatcher === null) { + return; + } + + $payload = (array)($messageData['payload'] ?? []); + $data = (array)($payload['data'] ?? []); + $delivery = (array)($data['delivery'] ?? []); + $sourceApp = (string)($delivery['sourceApp'] ?? ''); + $correlationId = (string)($delivery['correlationId'] ?? ''); + if ($sourceApp === '' || $correlationId === '') { + // Not an ADR-041 delivery request — nothing to conclude. + return; + } + + $error = null; + if (isset($messageData['error']) === true && (string)$messageData['error'] !== '') { + $error = (string)$messageData['error']; + } + + try { + $this->eventDispatcher->dispatchTyped( + new DeliveryConcludedEvent( + sourceApp: $sourceApp, + correlationId: $correlationId, + subjectId: (string)($delivery['subjectId'] ?? ''), + channel: (string)($delivery['channel'] ?? ''), + status: $status, + eventId: (string)($messageData['event'] ?? ''), + messageId: (string)$message->getUuid(), + attempts: count((array)($messageData['attempts'] ?? [])), + error: $error, + concludedAt: $concludedAt, + ) + ); + } catch (\Throwable $e) { + $this->logger->error( + 'DeliveryConcludedEvent dispatch failed: ' . $e->getMessage(), + [ + 'exception' => $e, + 'messageId' => $message->getUuid(), + 'sourceApp' => $sourceApp, + ] + ); + }//end try + }//end dispatchDeliveryConcluded() + /** * Persist a configuration-error failure (e.g. an unrecognised * `action.kind`): `status='failed'` with a descriptive error, WITHOUT @@ -2289,6 +2397,67 @@ public function emitCloudEvent(string $type, string $source, ?string $subject, a return $this->processEvent(event: $event); }//end emitCloudEvent() + /** + * Ingest an ADR-041 cross-app delivery request into the CloudEvents + * pipeline. + * + * Persists a {@see self::DELIVERY_REQUESTED_TYPE} `event` OR object whose + * `data.delivery` block carries the request's provenance (sourceApp, + * subject, channel, correlationId) and whose `data.payload` carries the + * caller-composed delivery payload, then fans it out via + * {@see processEvent} so admin-configured `event_subscription`s route it + * to a webhook / flow / synchronization / notificaties action with the + * pipeline's retry, dead-letter and replay semantics. + * + * The provenance block is what later gates the terminal + * {@see DeliveryConcludedEvent} dispatch in + * {@see dispatchDeliveryConcluded} — ordinary CloudEvent traffic carries + * no `data.delivery` and never produces one. + * + * @param DeliveryRequestedEvent $request The typed cross-app delivery request. + * + * @return array{event: ObjectEntity, messages: ObjectEntity[]} The persisted event and its created delivery messages. + * + * @throws Exception On event processing failure. + * @throws \OCP\DB\Exception On persistence failure. + * + * @spec openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md + */ + public function ingestDeliveryRequest(DeliveryRequestedEvent $request): array { + $event = $this->objectService->saveObject( + object: [ + 'source' => ('/apps/' . $request->getSourceApp() . '/delivery'), + 'type' => self::DELIVERY_REQUESTED_TYPE, + 'time' => (new DateTime())->format('c'), + 'subject' => $request->getSubjectId(), + 'data' => [ + 'delivery' => [ + 'sourceApp' => $request->getSourceApp(), + 'subjectRegister' => $request->getSubjectRegister(), + 'subjectSchema' => $request->getSubjectSchema(), + 'subjectId' => $request->getSubjectId(), + 'subjectLabel' => $request->getSubjectLabel(), + 'deliveryKind' => $request->getDeliveryKind(), + 'channel' => $request->getChannel(), + 'correlationId' => $request->getCorrelationId(), + 'externalReference' => $request->getExternalReference(), + ], + 'payload' => $request->getPayload(), + ], + 'userId' => $request->getUserId(), + ], + register: 'integriq', + schema: 'event' + ); + + $messages = $this->processEvent(event: $event); + + return [ + 'event' => $event, + 'messages' => $messages, + ]; + }//end ingestDeliveryRequest() + /** * Normalize a Nextcloud-native core event (files/calendar/Tables/Forms) * into the same CloudEvents `event` OR-object shape diff --git a/openspec/changes/absorb-dossiq-deliveries/design.md b/openspec/changes/absorb-dossiq-deliveries/design.md new file mode 100644 index 000000000..80172c2a0 --- /dev/null +++ b/openspec/changes/absorb-dossiq-deliveries/design.md @@ -0,0 +1,47 @@ +# Design — absorb-dossiq-deliveries + +## Landing zone: the CloudEvents pipeline, not a new engine + +The integriq audit ranked four landing zones for a sibling app's delivery: a Flow node, an +`event_subscription` action, a bespoke provider quintet, and raw `CallService`. The seam lands on +the **event pipeline** because the requesting context is a backend transition handler (no user +session for the sibling-push controllers, no admin-authored flow at the dispatch site), and because +the pipeline already owns exactly the semantics a delivery needs: per-subscription retry policy, +exponential backoff, dead-letter + replay UI, HMAC signing, and status bookkeeping on +`event_message`. A flow can still do the actual transport — `action.kind = 'flow'` on the matching +subscription — so the seam composes with the wave-3 direction instead of competing with it. + +The legacy runners (`SynchronizationService`, `RuleService`, `JobService`, `FlowRunnerService`) are +never called directly by the seam; they are reachable only as subscription actions that already +existed. + +## The provenance gate + +`ingestDeliveryRequest()` writes the request's provenance into the event's `data.delivery` block. +`createEventMessage()` embeds the event's serialization in `event_message.payload`, so the terminal +hooks (`recordDeliverySuccess`, terminal `recordFailure`) can read +`payload.data.delivery.{sourceApp,correlationId}` without a second lookup. That block is the gate: +present → dispatch `DeliveryConcludedEvent`; absent → ordinary CloudEvent traffic, no conclusion. +`recordConfigurationError` does not conclude — a config error is operator-fixable and replayable, +not terminal. + +## Result-slot honesty + +`setMatchedSubscriptions()` exists so "accepted" and "will actually travel" are distinguishable. +Zero matches means the instance has no route for this delivery — the consumer records `unrouted` +and an operator configures a subscription; nothing pretends to deliver. This is the +fail-closed-refusal shape the fleet ruling requires. + +## Constructor compatibility + +`IEventDispatcher` joins the constructor as a nullable, defaulted final parameter — the same +pattern `ExecutionTraceService` used — so every pre-existing positional test instantiation keeps +working and DI supplies the real dispatcher in production. A null dispatcher simply skips +conclusions (unit-test contexts); the listener half is unaffected. + +## Replay semantics + +`replayMessage()` can revive an abandoned message. If the replay succeeds, a second conclusion +(`delivered`) is dispatched and supersedes the earlier `abandoned` at the consumer — consumers +MUST project last-terminal-state-wins (dossiq's listener does). This is deliberate: the message +record and the consumer's projection converge without a tombstone protocol. diff --git a/openspec/changes/absorb-dossiq-deliveries/proposal.md b/openspec/changes/absorb-dossiq-deliveries/proposal.md new file mode 100644 index 000000000..6e3f36b8d --- /dev/null +++ b/openspec/changes/absorb-dossiq-deliveries/proposal.md @@ -0,0 +1,74 @@ +# Proposal: absorb-dossiq-deliveries + +kind: capability — cites **ADR-041** (hydra org-wide: cross-app commands via typed events), +**ADR-013** (event-bus model) and the `events-cloudevents` spec. Coupled to the dossiq change +`dossiq-delivers-nothing`, which ships the requesting half. Train order: this PR merges first — it +defines the event contract dossiq's `class_exists()`-guarded dispatch resolves; dossiq's half fails +closed until then, so no ordering breakage either way. + +## Summary + +Fleet ruling: **case apps keep no delivery code — integrations belong to integriq.** This change +gives integriq the receiving half of the ADR-041 delivery seam so a sibling app (dossiq first) can +hand over an outbound delivery and get an honest, terminal answer back: + +1. **`OCA\Integriq\Event\DeliveryRequestedEvent`** — the typed cross-app command ("deliver this + payload on my behalf"), carrying provenance (`sourceApp`, subject register/schema/id/label), + `deliveryKind`, `channel`, a caller-composed payload, a `correlationId`, and a synchronous + result slot (`isHandled` / `getResultId` / `getMatchedSubscriptions`). +2. **`DeliveryRequestedListener`** — ingests the request into the existing CloudEvents pipeline as + a `nl.conduction.delivery.requested` event whose `data.delivery` block carries the provenance; + admin-configured `event_subscription`s route it to a webhook / flow / synchronization / + notificaties action and inherit retry, backoff, dead-letter, replay and HMAC signing unchanged. + Zero matched subscriptions is reported honestly so the consumer fail-closes as "unrouted". +3. **`OCA\Integriq\Event\DeliveryConcludedEvent`** — dispatched from the `event_message` state + machine when a provenance-carrying delivery reaches a terminal state: `delivered` on success, + `abandoned` when the retry budget is spent. Ordinary CloudEvent traffic (no provenance block) + never produces one. The consumer projects the outcome onto its own domain record (dossiq: the + case's publication entry). + +No new transport, no new engine: the seam is a thin typed-event skin over `EventService`, and it +deliberately does NOT touch the wave-3 retirement targets (`SynchronizationService`, `RuleService`, +`JobService`, `FlowRunnerService` are not called directly — a flow can still be the *subscription's +action*). + +## Why + +ADR-041 requires cross-app commands to travel as typed events defined by the target app; integriq +had no such contract (no ADR-041 recipe existed in this repo before this change). Meanwhile every +delivery-shaped surface dossiq carries is either unreachable, mocked, or retry-less, and integriq +already operates the machinery all of them need. The seam lets sibling apps shed transport without +integriq growing bespoke per-app code: one contract, provenance-routed subscriptions. + +The sibling-push controllers (`stufZkn#outbound`, `iwmoIjw#createMessage`, ...) stay: they serve +session-carrying frontend calls. The event seam serves backend/flow contexts where a server-side +HTTP call would 401 (the exact phantom ADR-041 documents). + +## What + +1. `lib/Event/DeliveryRequestedEvent.php` + `lib/Event/DeliveryConcludedEvent.php` (new). +2. `lib/EventListener/DeliveryRequestedListener.php` (new), registered in `Application::boot()`. +3. `EventService::ingestDeliveryRequest()` (new public method): persists the provenance-carrying + `event` object, fans out via `processEvent()`, returns event + created messages. +4. `EventService` terminal-state hooks: `recordDeliverySuccess()` and the terminal branch of + `recordFailure()` dispatch `DeliveryConcludedEvent` via a new nullable `IEventDispatcher` + constructor dependency (nullable + defaulted, same test-compatibility pattern as + `ExecutionTraceService`). Dispatch failures are logged and swallowed — the message record stays + the source of truth. +5. Unit tests: `EventServiceDeliverySeamTest` (ingest shape, delivered/abandoned dispatch, no + dispatch without provenance or on non-terminal failure), `DeliveryRequestedListenerTest` + (result-slot write-back, unhandled-on-ingest-failure, foreign-event ignore). + +## Follow-ups staged in tasks.md + +Phase 2 tracks the integriq-side halves of dossiq's staged extractions: StUF endpoint/credential +migration intake, a per-callback notificaties routing decision, and (on commission) real +Berichtenbox / DROP-LVBB transports as provider quintets. Each carries its blocker honestly. + +## Non-goals + +- A delivery-specific message schema: `event_message` + the CloudEvent `data.delivery` block + already carry everything the seam needs (the `*_message` quintet pattern stays reserved for + bespoke wire protocols with their own inbound leg). +- Replay semantics changes: a replayed abandoned message that later succeeds simply dispatches a + second, superseding `delivered` conclusion — consumers project last-terminal-state-wins. diff --git a/openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md b/openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md new file mode 100644 index 000000000..e3ed41897 --- /dev/null +++ b/openspec/changes/absorb-dossiq-deliveries/specs/delivery-intake/spec.md @@ -0,0 +1,86 @@ +# delivery-intake Specification + +**Status:** proposed +**Scope:** integriq +**Tier:** V1 +**Depends on:** `events-cloudevents` spec (the `event`/`event_subscription`/`event_message` +pipeline this seam rides), Nextcloud `OCP\EventDispatcher\IEventDispatcher`. + +## Purpose + +The ADR-041 cross-app delivery seam: a sibling Conduction app composes WHAT must be delivered and +raises a typed event; integriq owns HOW it travels by fanning the request out through its +CloudEvents pipeline, and answers with a terminal conclusion the consumer projects onto its own +domain record. + +@e2e exclude The seam is a backend-only in-process typed-event exchange with no integriq browser +surface of its own: requests and conclusions surface in the existing Events / DeadLetters pages, +which have their own coverage. The seam behaviours are proven by the PHPUnit suites +(EventServiceDeliverySeamTest, DeliveryRequestedListenerTest) on this side and dossiq's +PublicationServiceTest / DeliveryConcludedListenerTest on the consumer side. + +## ADDED Requirements + +### Requirement: A delivery request is a typed event with a synchronous result slot + +Integriq SHALL expose `OCA\Integriq\Event\DeliveryRequestedEvent` carrying provenance +(`sourceApp`, `subjectRegister`, `subjectSchema`, `subjectId`, `subjectLabel`), a `deliveryKind`, +a `channel`, a caller-composed `payload`, a `correlationId`, and optional `externalReference` / +`userId`. The in-process listener SHALL write the result slot: `setHandled(true)`, the persisted +CloudEvent uuid via `setResultId()`, and the matched-subscription count via +`setMatchedSubscriptions()`. On ingest failure the event SHALL stay unhandled so the consumer +fail-closes. + +#### Scenario: A handled request carries the result slot + +- **GIVEN** the CloudEvents pipeline persists the request and one subscription matches +- **WHEN** the listener handles a `DeliveryRequestedEvent` +- **THEN** `isHandled()` MUST be true, `getResultId()` MUST be the event uuid, and + `getMatchedSubscriptions()` MUST be 1 + +#### Scenario: An ingest failure leaves the request unhandled + +- **WHEN** persisting or fanning out the request throws +- **THEN** the event MUST stay unhandled and MUST carry no result id + +### Requirement: Delivery requests ride the CloudEvents pipeline unchanged + +The listener SHALL persist the request as an `event` object of type +`nl.conduction.delivery.requested` with source `/apps//delivery`, the subject id as the +CloudEvents subject, and a `data.delivery` block carrying the full provenance, then fan it out via +`processEvent()`. Routing, retry, backoff, dead-letter, replay and HMAC signing SHALL be the +existing `event_subscription` / `event_message` machinery — no delivery-specific engine, and no +direct call into the legacy synchronization/rule/job runners. + +#### Scenario: The persisted event carries provenance + +- **WHEN** a request from `dossiq` for channel `gemeenteblad` is ingested +- **THEN** the persisted event MUST have type `nl.conduction.delivery.requested`, source + `/apps/dossiq/delivery`, and `data.delivery.sourceApp = 'dossiq'` with the correlation id + +### Requirement: A provenance-carrying delivery concludes with a typed terminal event + +When an `event_message` whose originating event carries a `data.delivery` provenance block reaches +a terminal state, integriq SHALL dispatch `OCA\Integriq\Event\DeliveryConcludedEvent` — +`delivered` from the success path, `abandoned` when the retry budget is spent — echoing +`sourceApp`, `correlationId`, `subjectId` and `channel`, with the attempt count, the last error (or +null) and the terminal timestamp. Ordinary CloudEvent traffic without the provenance block SHALL +never produce a conclusion, a non-terminal failure SHALL not conclude, and a conclusion-dispatch +failure SHALL be logged and swallowed — the message record stays the source of truth. + +#### Scenario: Success concludes delivered + +- **GIVEN** a pending message whose event data carries `delivery.sourceApp` and `correlationId` +- **WHEN** delivery succeeds +- **THEN** a `DeliveryConcludedEvent` with status `delivered` and the echoed correlation id MUST be + dispatched + +#### Scenario: A spent retry budget concludes abandoned + +- **WHEN** a provenance-carrying message fails with no retries remaining +- **THEN** a `DeliveryConcludedEvent` with status `abandoned` and the last error MUST be dispatched + +#### Scenario: Ordinary traffic never concludes + +- **WHEN** a message without a `data.delivery` provenance block reaches any terminal state +- **THEN** no `DeliveryConcludedEvent` is dispatched diff --git a/openspec/changes/absorb-dossiq-deliveries/tasks.md b/openspec/changes/absorb-dossiq-deliveries/tasks.md new file mode 100644 index 000000000..58a6fcf46 --- /dev/null +++ b/openspec/changes/absorb-dossiq-deliveries/tasks.md @@ -0,0 +1,42 @@ +# Tasks — absorb dossiq deliveries: the ADR-041 delivery seam + +## Phase 1: The delivery seam (this PR) + +- [x] `lib/Event/DeliveryRequestedEvent.php` — provenance + payload + synchronous result slot + (`setHandled`/`isHandled`, `setResultId`/`getResultId`, `setMatchedSubscriptions`). +- [x] `lib/Event/DeliveryConcludedEvent.php` — terminal outcome envelope (`delivered` / + `abandoned`, attempts, error, concludedAt), echoing sourceApp + correlationId + subject. +- [x] `lib/EventListener/DeliveryRequestedListener.php` — ingest via + `EventService::ingestDeliveryRequest()`, write the result slot; leave unhandled on ingest + failure so the consumer fail-closes. +- [x] `EventService::ingestDeliveryRequest()` — persist the `nl.conduction.delivery.requested` + CloudEvent with the `data.delivery` provenance block, fan out via `processEvent()`. +- [x] `EventService::dispatchDeliveryConcluded()` — dispatched from `recordDeliverySuccess()` and + the terminal (`abandoned`) branch of `recordFailure()`, gated to provenance-carrying + messages; new nullable `IEventDispatcher` constructor dependency. +- [x] Register the listener in `Application::boot()`. +- [x] Unit tests: ingest event shape, delivered dispatch, abandoned dispatch with error, + no dispatch on non-terminal failure, no dispatch without provenance, listener result-slot + write-back, listener unhandled-on-failure, foreign-event ignore. + +## Phase 2: Intake halves of dossiq's staged extractions — staged + +- [ ] **StUF endpoint/credential migration intake.** Blocked on: migration design — dossiq's + `stufEndpoint` objects hold `vault://` refs resolved via dossiq `IAppConfig`; integriq + sources resolve through the OpenRegister credential broker. Needs a documented mapping + (dossiq repair step writes `source` objects `type=stuf-zkn` with broker refs; secrets are + re-entered or brokered, never copied blind). Tracked jointly with dossiq + `dossiq-delivers-nothing` phase 2. +- [ ] **Per-callback ZGW notificaties routing.** Blocked on: a design decision — dossiq's + notificaties fan-out is per-abonnement callback URLs; the seam carries one delivery request, + while subscriptions are admin-configured. Either the `notificaties` action kind gains + callback-from-payload support, or dossiq raises one request per callback. Decide before + dossiq phase 3 lands. +- [ ] **Berichtenbox (MijnOverheid) transport.** Blocked on: commissioning — no production + transport exists anywhere in the fleet (dossiq ships only a MockAdapter). When built, it is + an integriq provider quintet (controller + provider seam + sync service + `*_message` schema + + retry job, the StufZkn/IwmoIjw pattern) addressed by `deliveryKind: 'berichtenbox'`. +- [ ] **DROP/LVBB publication transport.** Blocked on: commissioning — no DROP/LVBB transport + exists in dossiq to move (its PublicationService was record-only); a real + bekendmaking-via-DROP delivery is new integriq work, addressed by the existing + `deliveryKind: 'besluit-publication'` routing on channel `gemeenteblad`. diff --git a/tests/Unit/EventListener/DeliveryRequestedListenerTest.php b/tests/Unit/EventListener/DeliveryRequestedListenerTest.php new file mode 100644 index 000000000..030f9966e --- /dev/null +++ b/tests/Unit/EventListener/DeliveryRequestedListenerTest.php @@ -0,0 +1,146 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Tests\Unit\EventListener; + +use OCA\Integriq\Event\DeliveryRequestedEvent; +use OCA\Integriq\EventListener\DeliveryRequestedListener; +use OCA\Integriq\Service\EventService; +use OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder; +use OCP\EventDispatcher\Event; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for DeliveryRequestedListener. + * + * @covers \OCA\Integriq\EventListener\DeliveryRequestedListener + * @covers \OCA\Integriq\Event\DeliveryRequestedEvent + * + * @uses \OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder + */ +class DeliveryRequestedListenerTest extends TestCase { + + /** + * The mocked EventService. + * + * @var EventService|\PHPUnit\Framework\MockObject\MockObject + */ + private $eventService; + + /** + * The listener under test. + * + * @var DeliveryRequestedListener + */ + private DeliveryRequestedListener $listener; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + $this->eventService = $this->getMockBuilder(EventService::class) + ->disableOriginalConstructor() + ->getMock(); + $this->listener = new DeliveryRequestedListener( + eventService: $this->eventService, + logger: $this->createMock(LoggerInterface::class) + ); + }//end setUp() + + /** + * Build a delivery request event. + * + * @return DeliveryRequestedEvent + */ + private function request(): DeliveryRequestedEvent { + return new DeliveryRequestedEvent( + sourceApp: 'dossiq', + subjectRegister: 'dossiq', + subjectSchema: 'case', + subjectId: 'case-1', + subjectLabel: 'Kapvergunning', + deliveryKind: 'besluit-publication', + channel: 'gemeenteblad', + payload: ['caseId' => 'case-1'], + correlationId: 'corr-1', + ); + }//end request() + + /** + * A successful ingest writes handled + resultId + matched count back onto + * the event. + * + * @return void + */ + public function testHandledRequestCarriesResultSlot(): void { + $eventEntity = ObjectServiceMockBuilder::objectEntity($this, ['type' => 'nl.conduction.delivery.requested'], 'evt-1'); + $message = ObjectServiceMockBuilder::objectEntity($this, ['status' => 'pending'], 'msg-1'); + $this->eventService->method('ingestDeliveryRequest')->willReturn( + [ + 'event' => $eventEntity, + 'messages' => [$message], + ] + ); + + $event = $this->request(); + $this->listener->handle($event); + + $this->assertTrue($event->isHandled()); + $this->assertSame('evt-1', $event->getResultId()); + $this->assertSame(1, $event->getMatchedSubscriptions()); + }//end testHandledRequestCarriesResultSlot() + + /** + * An ingest failure leaves the event unhandled — the consumer's + * fail-closed guard then records the refusal. + * + * @return void + */ + public function testIngestFailureLeavesEventUnhandled(): void { + $this->eventService->method('ingestDeliveryRequest') + ->willThrowException(new \RuntimeException('register unavailable')); + + $event = $this->request(); + $this->listener->handle($event); + + $this->assertFalse($event->isHandled()); + $this->assertNull($event->getResultId()); + }//end testIngestFailureLeavesEventUnhandled() + + /** + * A non-delivery event is ignored. + * + * @return void + */ + public function testIgnoresForeignEvents(): void { + $this->eventService->expects($this->never())->method('ingestDeliveryRequest'); + $this->listener->handle(new class extends Event { + }); + }//end testIgnoresForeignEvents() +}//end class diff --git a/tests/Unit/Service/EventServiceDeliverySeamTest.php b/tests/Unit/Service/EventServiceDeliverySeamTest.php new file mode 100644 index 000000000..78a44dee1 --- /dev/null +++ b/tests/Unit/Service/EventServiceDeliverySeamTest.php @@ -0,0 +1,284 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-License-Identifier: EUPL-1.2 + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Integriq\Tests\Unit\Service; + +use OCA\Integriq\Event\DeliveryConcludedEvent; +use OCA\Integriq\Event\DeliveryRequestedEvent; +use OCA\Integriq\Service\CallService; +use OCA\Integriq\Service\EventService; +use OCA\Integriq\Service\FlowRunnerService; +use OCA\Integriq\Service\JobService; +use OCA\Integriq\Service\SynchronizationService; +use OCA\Integriq\Service\WebhookSignatureService; +use OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Http\Client\IClientService; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Unit tests for the EventService delivery seam. + * + * @covers \OCA\Integriq\Service\EventService + * @covers \OCA\Integriq\Event\DeliveryRequestedEvent + * @covers \OCA\Integriq\Event\DeliveryConcludedEvent + * + * @uses \OCA\Integriq\Service\WebhookSignatureService + * @uses \OCA\Integriq\Tests\Helpers\ObjectServiceMockBuilder + */ +class EventServiceDeliverySeamTest extends TestCase { + + /** + * The mocked OR ObjectService. + * + * @var \OCA\OpenRegister\Service\ObjectService|\PHPUnit\Framework\MockObject\MockObject + */ + private $objectService; + + /** + * The mocked event dispatcher. + * + * @var IEventDispatcher|\PHPUnit\Framework\MockObject\MockObject + */ + private $eventDispatcher; + + /** + * The service under test. + * + * @var EventService + */ + private EventService $service; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->objectService = ObjectServiceMockBuilder::make($this); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $logger = $this->createMock(LoggerInterface::class); + + $this->service = new EventService( + $this->objectService, + $this->createMock(IClientService::class), + $logger, + new WebhookSignatureService($logger), + $this->createMock(SynchronizationService::class), + $this->createMock(JobService::class), + $this->createMock(CallService::class), + $this->createMock(FlowRunnerService::class), + null, + null, + null, + null, + $this->eventDispatcher, + ); + }//end setUp() + + /** + * Build the persisted data of a provenance-carrying event_message. + * + * @param int $attempts How many attempts the message carries. + * @param string|null $error The last error on the message. + * + * @return array + */ + private function provenanceMessageData(int $attempts = 1, ?string $error = null): array { + $attemptRows = []; + for ($i = 0; $i < $attempts; $i++) { + $attemptRows[] = ['at' => '2026-09-02T12:00:0' . $i . '+00:00', 'statusCode' => null, 'error' => null]; + } + + return [ + 'event' => 'evt-1', + 'subscription' => 'sub-1', + 'status' => 'pending', + 'attempts' => $attemptRows, + 'error' => $error, + 'payload' => [ + 'data' => [ + 'delivery' => [ + 'sourceApp' => 'dossiq', + 'subjectId' => 'case-1', + 'channel' => 'gemeenteblad', + 'correlationId' => 'corr-1', + ], + 'payload' => ['caseId' => 'case-1'], + ], + ], + ]; + }//end provenanceMessageData() + + /** + * Ingesting a delivery request persists a provenance-carrying CloudEvent + * and fans it out through processEvent. + * + * @return void + */ + public function testIngestDeliveryRequestPersistsProvenanceEvent(): void { + $request = new DeliveryRequestedEvent( + sourceApp: 'dossiq', + subjectRegister: 'dossiq', + subjectSchema: 'case', + subjectId: 'case-1', + subjectLabel: 'Kapvergunning', + deliveryKind: 'besluit-publication', + channel: 'gemeenteblad', + payload: ['caseId' => 'case-1'], + correlationId: 'corr-1', + ); + + $savedObject = null; + $eventEntity = ObjectServiceMockBuilder::objectEntity($this, [], 'evt-1'); + $this->objectService->method('saveObject')->willReturnCallback( + static function (array $object) use (&$savedObject, $eventEntity) { + $savedObject = $object; + return $eventEntity; + } + ); + // No active subscriptions: processEvent matches nothing. + $this->objectService->method('findAll')->willReturn(['results' => []]); + + $result = $this->service->ingestDeliveryRequest(request: $request); + + $this->assertSame('evt-1', $result['event']->getUuid()); + $this->assertSame([], $result['messages']); + $this->assertNotNull($savedObject); + $this->assertSame(EventService::DELIVERY_REQUESTED_TYPE, $savedObject['type']); + $this->assertSame('/apps/dossiq/delivery', $savedObject['source']); + $this->assertSame('case-1', $savedObject['subject']); + $this->assertSame('dossiq', $savedObject['data']['delivery']['sourceApp']); + $this->assertSame('corr-1', $savedObject['data']['delivery']['correlationId']); + $this->assertSame(['caseId' => 'case-1'], $savedObject['data']['payload']); + }//end testIngestDeliveryRequestPersistsProvenanceEvent() + + /** + * A successful delivery of a provenance-carrying message dispatches the + * delivered conclusion. + * + * @return void + */ + public function testRecordDeliverySuccessDispatchesConcluded(): void { + $message = ObjectServiceMockBuilder::objectEntity($this, $this->provenanceMessageData(), 'msg-1'); + $this->objectService->method('saveObject')->willReturn($message); + + $dispatched = null; + $this->eventDispatcher->method('dispatchTyped')->willReturnCallback( + static function (object $event) use (&$dispatched): void { + $dispatched = $event; + } + ); + + $method = new \ReflectionMethod(EventService::class, 'recordDeliverySuccess'); + $method->invoke($this->service, $message); + + $this->assertInstanceOf(DeliveryConcludedEvent::class, $dispatched); + $this->assertSame('dossiq', $dispatched->getSourceApp()); + $this->assertSame('corr-1', $dispatched->getCorrelationId()); + $this->assertSame('case-1', $dispatched->getSubjectId()); + $this->assertSame('gemeenteblad', $dispatched->getChannel()); + $this->assertSame(DeliveryConcludedEvent::STATUS_DELIVERED, $dispatched->getStatus()); + $this->assertSame('evt-1', $dispatched->getEventId()); + $this->assertSame('msg-1', $dispatched->getMessageId()); + // recordDeliverySuccess appends the successful attempt. + $this->assertSame(2, $dispatched->getAttempts()); + $this->assertNull($dispatched->getError()); + }//end testRecordDeliverySuccessDispatchesConcluded() + + /** + * Spending the retry budget dispatches the abandoned conclusion with the + * last error. + * + * @return void + */ + public function testRecordFailureTerminalDispatchesAbandoned(): void { + $data = $this->provenanceMessageData(); + $data['retryCount'] = 0; + $message = ObjectServiceMockBuilder::objectEntity($this, $data, 'msg-1'); + $this->objectService->method('saveObject')->willReturn($message); + + $dispatched = null; + $this->eventDispatcher->method('dispatchTyped')->willReturnCallback( + static function (object $event) use (&$dispatched): void { + $dispatched = $event; + } + ); + + $method = new \ReflectionMethod(EventService::class, 'recordFailure'); + $method->invoke($this->service, $message, 'HTTP 503', 503, null, ['maxRetries' => 1]); + + $this->assertInstanceOf(DeliveryConcludedEvent::class, $dispatched); + $this->assertSame(DeliveryConcludedEvent::STATUS_ABANDONED, $dispatched->getStatus()); + $this->assertSame('HTTP 503', $dispatched->getError()); + $this->assertSame('corr-1', $dispatched->getCorrelationId()); + }//end testRecordFailureTerminalDispatchesAbandoned() + + /** + * A non-terminal failure (retry budget remaining) dispatches nothing. + * + * @return void + */ + public function testRecordFailureNonTerminalDispatchesNothing(): void { + $data = $this->provenanceMessageData(); + $data['retryCount'] = 0; + $message = ObjectServiceMockBuilder::objectEntity($this, $data, 'msg-1'); + $this->objectService->method('saveObject')->willReturn($message); + + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $method = new \ReflectionMethod(EventService::class, 'recordFailure'); + $method->invoke($this->service, $message, 'HTTP 503', 503, null, ['maxRetries' => 5]); + }//end testRecordFailureNonTerminalDispatchesNothing() + + /** + * Ordinary CloudEvent traffic — no provenance block — never produces a + * conclusion. + * + * @return void + */ + public function testNoConclusionWithoutProvenance(): void { + $message = ObjectServiceMockBuilder::objectEntity( + $this, + [ + 'event' => 'evt-2', + 'status' => 'pending', + 'attempts' => [], + 'payload' => ['data' => ['id' => 'obj-1']], + ], + 'msg-2' + ); + $this->objectService->method('saveObject')->willReturn($message); + + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $method = new \ReflectionMethod(EventService::class, 'recordDeliverySuccess'); + $method->invoke($this->service, $message); + }//end testNoConclusionWithoutProvenance() +}//end class diff --git a/tests/Unit/Service/EventServiceTest.php b/tests/Unit/Service/EventServiceTest.php index c91e5c62f..fa49b9471 100644 --- a/tests/Unit/Service/EventServiceTest.php +++ b/tests/Unit/Service/EventServiceTest.php @@ -588,7 +588,10 @@ public function testDeliverMessageSuccessMarksDelivered(): void { $this->assertSame(200, $captured['deliveryResponse']['statusCode']); $this->assertCount(1, $captured['attempts']); $this->assertSame(200, $captured['attempts'][0]['statusCode']); - $this->assertNull($captured['attempts'][0]['error']); + // appendAttempt() OMITS a null error rather than writing it — the + // schema types attempts[].error as string and OpenRegister refuses + // null for a nested array-item property (see appendAttempt()). + $this->assertArrayNotHasKey('error', $captured['attempts'][0]); }//end testDeliverMessageSuccessMarksDelivered() /** @@ -672,7 +675,10 @@ public function testDeliverMessageExceptionRecordsErrorAttempt(): void { $this->assertFalse($result); $this->assertSame('failed', $captured['status']); $this->assertCount(1, $captured['attempts']); - $this->assertNull($captured['attempts'][0]['statusCode']); + // A transport failure has no HTTP status by definition, and + // appendAttempt() OMITS the key rather than writing null — writing + // null used to fail schema validation and abort the retry sweep. + $this->assertArrayNotHasKey('statusCode', $captured['attempts'][0]); $this->assertNotNull($captured['attempts'][0]['error']); }//end testDeliverMessageExceptionRecordsErrorAttempt()