From a54a7cb6070f31f4811cb0040fd4b22427a5363b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 15 Oct 2026 19:48:54 +0200 Subject: [PATCH] feat(brp): count the people registered at an address object, a number and nothing else BrpPersonProvider::countResidentsAtAddressObject() asks Haal Centraal ZoekMetAdresseerbaarObjectIdentificatie with one field and answers { total, meta }. It asks only when the brp-haalcentraal source carries configuration.residentCount: true, and the call reaches the connector with withholdBodies so the residents' numbers are never stored. The router gains sourceConfiguration() and the withholdBodies call option. This is the openregister half of portaliq identity-registered-details T07. Assisted-by: ClaudeCode:claude-opus-5-5 --- .../Integration/ExternalIntegrationRouter.php | 106 ++++++--- .../Providers/BrpPersonProvider.php | 69 ++++++ .../proposal.md | 34 +++ .../screens.md | 3 + .../specs/integration-person-lookup/spec.md | 53 +++++ .../tasks.md | 14 ++ .../integration-person-lookup/screens.md | 3 + .../specs/integration-person-lookup/spec.md | 52 +++++ .../ExternalIntegrationRouterTest.php | 108 +++++++++ .../BrpPersonProviderResidentCountTest.php | 210 ++++++++++++++++++ 10 files changed, 615 insertions(+), 37 deletions(-) create mode 100644 openspec/changes/archive/2026-10-15-brp-residents-at-address-count/proposal.md create mode 100644 openspec/changes/archive/2026-10-15-brp-residents-at-address-count/screens.md create mode 100644 openspec/changes/archive/2026-10-15-brp-residents-at-address-count/specs/integration-person-lookup/spec.md create mode 100644 openspec/changes/archive/2026-10-15-brp-residents-at-address-count/tasks.md create mode 100644 openspec/specs/integration-person-lookup/screens.md create mode 100644 tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php diff --git a/lib/Service/Integration/ExternalIntegrationRouter.php b/lib/Service/Integration/ExternalIntegrationRouter.php index 84cc9f400e..ca38dcfa93 100644 --- a/lib/Service/Integration/ExternalIntegrationRouter.php +++ b/lib/Service/Integration/ExternalIntegrationRouter.php @@ -264,6 +264,27 @@ public function callWithMeta( }//end try }//end callWithMeta() + /** + * The `configuration` array of the provider's connector source, so a + * provider can ask only what its source allows. + * + * @param IntegrationProvider $provider The provider whose source to read. + * + * @return array The source's `configuration` (possibly empty). + * + * @throws ProviderUnavailableException When the connector or the source is missing. + * + * @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies + */ + public function sourceConfiguration(IntegrationProvider $provider): array { + $this->assertProviderIsExternal(provider: $provider); + $this->assertOpenConnectorAvailable(); + + $source = $this->loadSource(sourceId: (string)$provider->getOpenConnectorSource(), providerId: $provider->getId()); + + return $this->readSourceConfiguration(source: $source); + }//end sourceConfiguration() + /** * Cheap "is the connector reachable at all" check. * @@ -639,14 +660,10 @@ private function callServiceClass(): string { /** * Invoke the upstream call via OpenConnector's CallService. * - * The CallService API has varied across OpenConnector versions; - * this method tries the canonical method names and falls back to - * an exception that the caller wraps as upstream-down. - * * @param mixed $source Resolved source entity. * @param string $method HTTP method. * @param string $path Path relative to source base URL. - * @param array $options Call options (query / body / headers). + * @param array $options Call options (query / body / headers / withholdBodies). * * @return array Decoded response body. * @@ -654,36 +671,19 @@ private function callServiceClass(): string { * caller wraps this as ProviderUnavailableException. */ private function invoke($source, string $method, string $path, array $options): array { - $callService = $this->container->get($this->callServiceClass()); - $options = $this->connectorOptions(options: $options); - $path = $this->connectorEndpoint(source: $source, path: $path); + $response = $this->dispatch(source: $source, method: $method, path: $path, options: $options); - if (method_exists($callService, 'call') === true) { - $response = $callService->call($source, $path, $method, $options); - $this->assertUpstreamOk(response: $response); - return $this->decodeResponse(response: $response); - } - - if (method_exists($callService, 'request') === true) { - $response = $callService->request($source, $method, $path, $options); - $this->assertUpstreamOk(response: $response); - return $this->decodeResponse(response: $response); - } - - throw new RuntimeException( - 'The connector CallService does not expose a known call/request method.' - ); + return $this->decodeResponse(response: $response); }//end invoke() /** * Invoke the upstream call like {@see invoke()} but return both the - * decoded body and the extracted response metadata. Keeps the same - * CallService method-name fallback + >= 400 status assertion. + * decoded body and the extracted response metadata. * * @param mixed $source Resolved source entity. * @param string $method HTTP method. * @param string $path Path relative to source base URL. - * @param array $options Call options (query / body / headers). + * @param array $options Call options (query / body / headers / withholdBodies). * * @return array{body: array, meta: array{status: int, durationMs: int, correlationId: ?string, headers: array}} * @@ -691,32 +691,64 @@ private function invoke($source, string $method, string $path, array $options): * wraps this as ProviderUnavailableException. */ private function invokeWithMeta($source, string $method, string $path, array $options): array { + $response = $this->dispatch(source: $source, method: $method, path: $path, options: $options); + + return [ + 'body' => $this->decodeResponse(response: $response), + 'meta' => $this->extractMeta(response: $response), + ]; + }//end invokeWithMeta() + + /** + * Hand the call to the connector's CallService and assert the upstream + * status. The CallService API has varied across OpenConnector versions, so + * this tries `call` and then `request`. + * + * With the `withholdBodies` option the connector records the call without + * its request and response bodies. A connector whose `call` has no such + * parameter is not called at all: PHP refuses an unknown named argument + * before the method runs, and the caller reports that as upstream-down. + * + * @param mixed $source Resolved source entity. + * @param string $method HTTP method. + * @param string $path Path relative to source base URL. + * @param array $options Call options (query / body / headers / withholdBodies). + * + * @return mixed The raw CallService response. + * + * @throws \RuntimeException When CallService is unreachable. + * + * @spec openspec/specs/integration-person-lookup/spec.md#requirement-a-call-may-be-recorded-without-its-bodies + */ + private function dispatch($source, string $method, string $path, array $options) { + $withholdBodies = (($options['withholdBodies'] ?? false) === true); + unset($options['withholdBodies']); + $callService = $this->container->get($this->callServiceClass()); $options = $this->connectorOptions(options: $options); $path = $this->connectorEndpoint(source: $source, path: $path); if (method_exists($callService, 'call') === true) { - $response = $callService->call($source, $path, $method, $options); + $named = []; + if ($withholdBodies === true) { + $named = ['withholdBodies' => true]; + } + + $response = $callService->call($source, $path, $method, $options, ...$named); $this->assertUpstreamOk(response: $response); - return [ - 'body' => $this->decodeResponse(response: $response), - 'meta' => $this->extractMeta(response: $response), - ]; + return $response; } - if (method_exists($callService, 'request') === true) { + if (method_exists($callService, 'request') === true && $withholdBodies === false) { $response = $callService->request($source, $method, $path, $options); $this->assertUpstreamOk(response: $response); - return [ - 'body' => $this->decodeResponse(response: $response), - 'meta' => $this->extractMeta(response: $response), - ]; + return $response; } throw new RuntimeException( 'The connector CallService does not expose a known call/request method.' ); - }//end invokeWithMeta() + }//end dispatch() /** * Extract transport metadata from a CallService response (OpenConnector diff --git a/lib/Service/Integration/Providers/BrpPersonProvider.php b/lib/Service/Integration/Providers/BrpPersonProvider.php index eac0e1b4cd..528bd0a73d 100644 --- a/lib/Service/Integration/Providers/BrpPersonProvider.php +++ b/lib/Service/Integration/Providers/BrpPersonProvider.php @@ -106,6 +106,21 @@ class BrpPersonProvider extends AbstractIntegrationProvider { * * @var array */ + /** + * The source configuration flag that allows the residents count. + */ + public const RESIDENT_COUNT_SETTING = 'residentCount'; + + /** + * Cause when the source does not allow the residents count. + */ + public const CAUSE_RESIDENT_COUNT_NOT_ENABLED = 'resident-count-not-enabled'; + + /** + * Cause when an address object id is not 16 digits. + */ + public const CAUSE_INVALID_IDENTIFIER = 'invalid-identifier'; + private const DEFAULT_FIELDS = [ 'burgerservicenummer', 'naam.voornamen', @@ -358,6 +373,60 @@ public function health(): array { return $this->router->probe(provider: $this); }//end health() + /** + * Count the people the BRP registers at an address object. + * + * Issues `ZoekMetAdresseerbaarObjectIdentificatie` with one field and + * keeps only the number of results. Asked only when the source carries + * `configuration.residentCount: true`; the call reaches the connector with + * `withholdBodies`, so the residents' numbers are never stored. Neither the + * answer nor the log holds a person or the address object id. + * + * @param string $addressObjectId The 16-digit adresseerbaarObjectIdentificatie. + * + * @return array `{ total, meta }`, or `{ unavailable, cause }`. + * + * @spec openspec/specs/integration-person-lookup/spec.md#requirement-brp-count-of-residents-at-an-address-object + */ + public function countResidentsAtAddressObject(string $addressObjectId): array { + $addressObjectId = trim($addressObjectId); + if (preg_match('/^\d{16}$/', $addressObjectId) !== 1) { + return ['unavailable' => true, 'cause' => self::CAUSE_INVALID_IDENTIFIER]; + } + + try { + $configuration = $this->router->sourceConfiguration(provider: $this); + if (($configuration[self::RESIDENT_COUNT_SETTING] ?? null) !== true) { + return ['unavailable' => true, 'cause' => self::CAUSE_RESIDENT_COUNT_NOT_ENABLED]; + } + + $envelope = $this->router->callWithMeta( + provider: $this, + method: 'POST', + path: 'personen', + options: [ + 'body' => [ + 'type' => 'ZoekMetAdresseerbaarObjectIdentificatie', + 'adresseerbaarObjectIdentificatie' => $addressObjectId, + 'fields' => ['burgerservicenummer'], + ], + 'headers' => ['Accept' => 'application/hal+json', 'Content-Type' => 'application/json'], + 'withholdBodies' => true, + ] + ); + } catch (ProviderUnavailableException $e) { + return ['unavailable' => true, 'cause' => $e->getCause()]; + } catch (Throwable $e) { + $this->logger->warning('BrpPersonProvider::countResidentsAtAddressObject failed (transport).'); + return ['unavailable' => true, 'cause' => ProviderUnavailableException::CAUSE_UPSTREAM_SERVICE_DOWN]; + }//end try + + return [ + 'total' => count($this->extractPersonen(response: ($envelope['body'] ?? []))), + 'meta' => $this->shapeMeta(meta: ($envelope['meta'] ?? [])), + ]; + }//end countResidentsAtAddressObject() + /** * Build the `RaadpleegMetBurgerservicenummer` query body for the * HaalCentraal `/personen` POST. The BSN list + the explicit `fields` diff --git a/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/proposal.md b/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/proposal.md new file mode 100644 index 0000000000..10a9de76c0 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/proposal.md @@ -0,0 +1,34 @@ +--- +kind: code +--- + +# Proposal: brp-residents-at-address-count + +## Summary + +Let the BRP provider answer how many people are registered at an address object, as a number and nothing else. Portaliq's "Mijn gegevens" shows a resident that number for their own address, as MijnOverheid and NL Portal (`getBewonersAantalV2`) do. + +Rows covered: portaliq `cmp-id-residents-at-address` (REQ-IRD-005). This is OpenRegister's half of portaliq's `identity-registered-details` task T07. + +## Why + +Portaliq reads the BRP only through `BrpPersonProvider`, which answers `RaadpleegMetBurgerservicenummer` only. Portaliq's section already says "The number of residents at this address is not available" and waits for a method it can call (`method_exists`). + +## What changes + +- **`BrpPersonProvider::countResidentsAtAddressObject(addressObjectId)`.** Asks Haal Centraal `ZoekMetAdresseerbaarObjectIdentificatie` with the smallest field set and answers `{ total, meta }`. No names, no BSNs and no person object leave the provider. +- **Behind a setting on the source.** The question is asked only when the `brp-haalcentraal` source carries `configuration.residentCount: true`, the way integriq reads BRP relations only behind `configuration.relationFields: true` (decision 220). Without it the answer is `{ unavailable, cause: 'resident-count-not-enabled' }` and the registry is not asked. +- **Recorded without bodies.** The answer holds the residents' numbers, so the call goes to the connector with `withholdBodies`: integriq records that the call happened, not what it carried. A connector that cannot withhold bodies is not called. +- **Router.** `ExternalIntegrationRouter` gains `sourceConfiguration(provider)` and honours a `withholdBodies` call option. Both are generic: any provider may use them. + +## Out of scope + +- Who may see the count (the resident, for their own address) and how it renders: portaliq. +- Names or any other detail of the residents. +- Seeding `residentCount` on integriq's source: an administrator turns it on. + +## Impact + +- `lib/Service/Integration/Providers/BrpPersonProvider.php` +- `lib/Service/Integration/ExternalIntegrationRouter.php` +- Tests: `BrpPersonProviderResidentCountTest`, `ExternalIntegrationRouterTest` diff --git a/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/screens.md b/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/screens.md new file mode 100644 index 0000000000..59130f236b --- /dev/null +++ b/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/screens.md @@ -0,0 +1,3 @@ +# Screens + +- No screen: a provider method and a router option. Portaliq's Mijn gegevens shows the number; that screen belongs to portaliq's `identity-registered-details`. diff --git a/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/specs/integration-person-lookup/spec.md b/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/specs/integration-person-lookup/spec.md new file mode 100644 index 0000000000..88d02d70ab --- /dev/null +++ b/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/specs/integration-person-lookup/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: BRP count of residents at an address object + +`BrpPersonProvider::countResidentsAtAddressObject(string $addressObjectId)` SHALL answer how many people the BRP registers at an address object, as a number only. It SHALL ask Haal Centraal `/personen` with a `ZoekMetAdresseerbaarObjectIdentificatie` body carrying the 16-digit `adresseerbaarObjectIdentificatie` and a `fields` list of one field, and answer `{ total, meta }` where `meta` is the Wet-BRP audit metadata `{ correlationId, durationMs, status }`. + +The provider SHALL ask only when the `brp-haalcentraal` source carries `configuration.residentCount` set to the boolean `true`. Otherwise it SHALL answer `{ unavailable: true, cause: 'resident-count-not-enabled' }` without calling the registry. + +The call SHALL reach the connector with `withholdBodies`, so the call log keeps no request or response body. A connector that cannot withhold bodies SHALL NOT be called, and the answer is `{ unavailable: true, cause: 'upstream-service-down' }`. + +The answer SHALL never hold a name, a BSN, a person object or the address object id, and the provider SHALL never log the address object id. A malformed id SHALL answer `{ unavailable: true, cause: 'invalid-identifier' }` without a call. A missing or unreachable source SHALL degrade to `{ unavailable: true, cause }` as `lookupByBsn` does. + +#### Scenario: the count of residents at an address object + +- GIVEN the `brp-haalcentraal` source carries `configuration.residentCount: true` +- AND the BRP registers three people at address object `0518010000123456` +- WHEN portaliq asks `countResidentsAtAddressObject('0518010000123456')` +- THEN the provider posts `type='ZoekMetAdresseerbaarObjectIdentificatie'`, the id in `adresseerbaarObjectIdentificatie` and one field, with `withholdBodies` +- AND it answers `{ total: 3, meta }` and nothing about the three people +- @e2e exclude Backend provider method, verified by PHPUnit (router mocked); portaliq's Mijn gegevens is the live pass. + +#### Scenario: the setting is off + +- GIVEN the source carries no `configuration.residentCount`, or a value other than `true` +- WHEN the count is asked +- THEN the answer is `{ unavailable: true, cause: 'resident-count-not-enabled' }` and the registry is not called +- @e2e exclude Backend refusal path, verified by PHPUnit. + +#### Scenario: no number and no address in the answer or the log + +- GIVEN any count, answered or degraded +- WHEN the provider answers and logs +- THEN the answer holds only `total` and `meta`, or `unavailable` and `cause` +- AND no log line holds the address object id or a burgerservicenummer +- @e2e exclude Backend privacy invariant, verified by PHPUnit (logger asserted). + +### Requirement: a call may be recorded without its bodies + +`ExternalIntegrationRouter` SHALL honour a `withholdBodies: true` call option by passing `withholdBodies: true` to the connector's `CallService::call`, and SHALL never pass that option on as a request option. When the connector's `call` has no `withholdBodies` parameter, the router SHALL NOT call it and SHALL fail with cause `upstream-service-down`. `sourceConfiguration(provider)` SHALL answer the provider's source `configuration` array and fail like `call()` when the connector or the source is missing. + +#### Scenario: the connector records the call without bodies + +- GIVEN integriq's CallService, whose `call` takes `withholdBodies` +- WHEN a provider calls with `withholdBodies: true` +- THEN the CallService receives `withholdBodies: true` and the request options do not carry it +- @e2e exclude Backend transport option, verified by PHPUnit (CallService stand-in). + +#### Scenario: an older connector is not called + +- GIVEN a CallService whose `call` has no `withholdBodies` parameter +- WHEN a provider calls with `withholdBodies: true` +- THEN the CallService is not called and the router fails with `upstream-service-down` +- @e2e exclude Backend refusal path, verified by PHPUnit. diff --git a/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/tasks.md b/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/tasks.md new file mode 100644 index 0000000000..fa16f824f8 --- /dev/null +++ b/openspec/changes/archive/2026-10-15-brp-residents-at-address-count/tasks.md @@ -0,0 +1,14 @@ +# Tasks: brp-residents-at-address-count + +Kind: code. Size S. Row: portaliq `cmp-id-residents-at-address`. + +## 1. Router + +- [x] 1.1 `ExternalIntegrationRouter::sourceConfiguration()` answers the source's `configuration`, and fails like `call()` when the connector or the source is missing. Verify: `ExternalIntegrationRouterTest`. +- [x] 1.2 A `withholdBodies` call option reaches integriq's `CallService::call(withholdBodies: true)`, never Guzzle; a CallService without that parameter is not called. Verify: `ExternalIntegrationRouterTest`. + +## 2. Provider + +- [x] 2.1 `BrpPersonProvider::countResidentsAtAddressObject()` posts `ZoekMetAdresseerbaarObjectIdentificatie` with one field, behind `configuration.residentCount: true`, and answers `{ total, meta }` or `{ unavailable, cause }`. Verify: `BrpPersonProviderResidentCountTest` covers the count, the setting off, a malformed id, the degraded paths, and that no number or address id reaches the result or the log. + +The administrator-facing note (turn on `residentCount` on the `brp-haalcentraal` source) lives on portaliq's registered-details docs page, next to the sources it already names. diff --git a/openspec/specs/integration-person-lookup/screens.md b/openspec/specs/integration-person-lookup/screens.md new file mode 100644 index 0000000000..4c06e3fd2f --- /dev/null +++ b/openspec/specs/integration-person-lookup/screens.md @@ -0,0 +1,3 @@ +# Screens + +- No screen: the BRP person lookup and the residents count are provider methods and an API; the consuming apps (portaliq, pipelinq) own the screens that show them. diff --git a/openspec/specs/integration-person-lookup/spec.md b/openspec/specs/integration-person-lookup/spec.md index ffcb36f2ba..119fb55abb 100644 --- a/openspec/specs/integration-person-lookup/spec.md +++ b/openspec/specs/integration-person-lookup/spec.md @@ -157,3 +157,55 @@ that both transports are configured on the OpenConnector source. - WHEN the provider or controller writes a log line - THEN the log line contains neither the raw BSN nor the request/response body - @e2e exclude Backend privacy invariant — verified by code review + PHPUnit (logger mocked, asserted not to receive the BSN), not a browser flow. + +### Requirement: BRP count of residents at an address object + +`BrpPersonProvider::countResidentsAtAddressObject(string $addressObjectId)` SHALL answer how many people the BRP registers at an address object, as a number only. It SHALL ask Haal Centraal `/personen` with a `ZoekMetAdresseerbaarObjectIdentificatie` body carrying the 16-digit `adresseerbaarObjectIdentificatie` and a `fields` list of one field, and answer `{ total, meta }` where `meta` is the Wet-BRP audit metadata `{ correlationId, durationMs, status }`. + +The provider SHALL ask only when the `brp-haalcentraal` source carries `configuration.residentCount` set to the boolean `true`. Otherwise it SHALL answer `{ unavailable: true, cause: 'resident-count-not-enabled' }` without calling the registry. + +The call SHALL reach the connector with `withholdBodies`, so the call log keeps no request or response body. A connector that cannot withhold bodies SHALL NOT be called, and the answer is `{ unavailable: true, cause: 'upstream-service-down' }`. + +The answer SHALL never hold a name, a BSN, a person object or the address object id, and the provider SHALL never log the address object id. A malformed id SHALL answer `{ unavailable: true, cause: 'invalid-identifier' }` without a call. A missing or unreachable source SHALL degrade to `{ unavailable: true, cause }` as `lookupByBsn` does. + +#### Scenario: the count of residents at an address object + +- GIVEN the `brp-haalcentraal` source carries `configuration.residentCount: true` +- AND the BRP registers three people at address object `0518010000123456` +- WHEN portaliq asks `countResidentsAtAddressObject('0518010000123456')` +- THEN the provider posts `type='ZoekMetAdresseerbaarObjectIdentificatie'`, the id in `adresseerbaarObjectIdentificatie` and one field, with `withholdBodies` +- AND it answers `{ total: 3, meta }` and nothing about the three people +- @e2e exclude Backend provider method, verified by PHPUnit (router mocked); portaliq's Mijn gegevens is the live pass. + +#### Scenario: the setting is off + +- GIVEN the source carries no `configuration.residentCount`, or a value other than `true` +- WHEN the count is asked +- THEN the answer is `{ unavailable: true, cause: 'resident-count-not-enabled' }` and the registry is not called +- @e2e exclude Backend refusal path, verified by PHPUnit. + +#### Scenario: no number and no address in the answer or the log + +- GIVEN any count, answered or degraded +- WHEN the provider answers and logs +- THEN the answer holds only `total` and `meta`, or `unavailable` and `cause` +- AND no log line holds the address object id or a burgerservicenummer +- @e2e exclude Backend privacy invariant, verified by PHPUnit (logger asserted). + +### Requirement: a call may be recorded without its bodies + +`ExternalIntegrationRouter` SHALL honour a `withholdBodies: true` call option by passing `withholdBodies: true` to the connector's `CallService::call`, and SHALL never pass that option on as a request option. When the connector's `call` has no `withholdBodies` parameter, the router SHALL NOT call it and SHALL fail with cause `upstream-service-down`. `sourceConfiguration(provider)` SHALL answer the provider's source `configuration` array and fail like `call()` when the connector or the source is missing. + +#### Scenario: the connector records the call without bodies + +- GIVEN integriq's CallService, whose `call` takes `withholdBodies` +- WHEN a provider calls with `withholdBodies: true` +- THEN the CallService receives `withholdBodies: true` and the request options do not carry it +- @e2e exclude Backend transport option, verified by PHPUnit (CallService stand-in). + +#### Scenario: an older connector is not called + +- GIVEN a CallService whose `call` has no `withholdBodies` parameter +- WHEN a provider calls with `withholdBodies: true` +- THEN the CallService is not called and the router fails with `upstream-service-down` +- @e2e exclude Backend refusal path, verified by PHPUnit. diff --git a/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php b/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php index 8ec1856841..a5719f648b 100644 --- a/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php +++ b/tests/Unit/Service/Integration/ExternalIntegrationRouterTest.php @@ -210,6 +210,44 @@ public function call($source, string $endpoint = '', string $method = 'GET', arr }//end call() }//end class +/** + * Stand-in for integriq's CallService, whose `call` takes `withholdBodies`: + * remembers the options and the flag it was handed. + */ +class WithholdingCallService { + /** + * The request options the router handed to call(). + * + * @var array|null + */ + public ?array $receivedConfig = null; + + /** + * The withholdBodies flag the router handed to call(). + * + * @var bool|null + */ + public ?bool $receivedWithholdBodies = null; + + public function __construct( + private object $log, + ) { + }//end __construct() + + public function call( + $source, + string $endpoint = '', + string $method = 'GET', + array $config = [], + bool $persistLog = true, + bool $withholdBodies = false, + ) { + $this->receivedConfig = $config; + $this->receivedWithholdBodies = $withholdBodies; + return $this->log; + }//end call() +}//end class + /** * Unit tests for ExternalIntegrationRouter. */ @@ -917,4 +955,74 @@ public function testCallWithMetaSendsAnArrayBodyAsJson(): void { $this->assertSame(['id' => 'm1'], $envelope['body']); $this->assertSame('{"type":"RaadpleegMetBurgerservicenummer"}', (string)$callService->sent[0]->getBody()); }//end testCallWithMetaSendsAnArrayBodyAsJson() + + public function testSourceConfigurationAnswersTheSourcesConfiguration(): void { + $source = $this->sourceEntity(['slug' => 'brp-haalcentraal', 'configuration' => ['residentCount' => true, 'mock' => false]]); + $router = $this->routerOn($this->objectService(['integriq/brp-haalcentraal' => $source]), new ExplodingCallService()); + + $this->assertSame( + ['residentCount' => true, 'mock' => false], + $router->sourceConfiguration(new FakeExternalProvider(source: 'brp-haalcentraal')) + ); + }//end testSourceConfigurationAnswersTheSourcesConfiguration() + + public function testSourceConfigurationFailsLikeCallWhenTheSourceIsMissing(): void { + $router = $this->routerOn($this->objectService([]), new ExplodingCallService()); + + try { + $router->sourceConfiguration(new FakeExternalProvider(source: 'brp-haalcentraal')); + $this->fail('Expected ProviderUnavailableException'); + } catch (ProviderUnavailableException $e) { + $this->assertSame(ProviderUnavailableException::CAUSE_OPENCONNECTOR_SOURCE_MISSING, $e->getCause()); + } + }//end testSourceConfigurationFailsLikeCallWhenTheSourceIsMissing() + + public function testWithheldBodiesReachTheConnectorAndNotTheRequestOptions(): void { + $source = $this->sourceEntity(['slug' => 'brp-haalcentraal', 'location' => 'https://brp.example']); + $callService = new WithholdingCallService( + $this->integriqCallLog(['statusCode' => 200, 'response' => ['statusCode' => 200, 'body' => '{"personen":[{},{}]}', 'encoding' => 'UTF-8']]) + ); + $router = $this->routerOn($this->objectService(['integriq/brp-haalcentraal' => $source]), $callService); + + $envelope = $router->callWithMeta( + new FakeExternalProvider(source: 'brp-haalcentraal'), + 'POST', + 'personen', + ['body' => ['type' => 'ZoekMetAdresseerbaarObjectIdentificatie'], 'withholdBodies' => true] + ); + + $this->assertTrue($callService->receivedWithholdBodies); + $this->assertArrayNotHasKey('withholdBodies', $callService->receivedConfig); + $this->assertSame(['type' => 'ZoekMetAdresseerbaarObjectIdentificatie'], $callService->receivedConfig['json']); + $this->assertCount(2, $envelope['body']['personen']); + }//end testWithheldBodiesReachTheConnectorAndNotTheRequestOptions() + + public function testWithoutTheOptionTheConnectorRecordsBodiesAsBefore(): void { + $source = $this->sourceEntity(['slug' => 'kvk', 'location' => 'https://kvk.example']); + $callService = new WithholdingCallService( + $this->integriqCallLog(['statusCode' => 200, 'response' => ['statusCode' => 200, 'body' => '{}', 'encoding' => 'UTF-8']]) + ); + $router = $this->routerOn($this->objectService(['integriq/kvk' => $source]), $callService); + + $router->call(new FakeExternalProvider(source: 'kvk'), 'GET', 'zoeken'); + + $this->assertFalse($callService->receivedWithholdBodies); + }//end testWithoutTheOptionTheConnectorRecordsBodiesAsBefore() + + public function testAConnectorThatCannotWithholdBodiesIsNotCalled(): void { + $source = $this->sourceEntity(['slug' => 'brp-haalcentraal', 'location' => 'https://brp.example']); + $callService = new FakeCallService( + $this->integriqCallLog(['statusCode' => 200, 'response' => ['statusCode' => 200, 'body' => '{}', 'encoding' => 'UTF-8']]) + ); + $router = $this->routerOn($this->objectService(['integriq/brp-haalcentraal' => $source]), $callService); + + try { + $router->callWithMeta(new FakeExternalProvider(source: 'brp-haalcentraal'), 'POST', 'personen', ['withholdBodies' => true]); + $this->fail('Expected ProviderUnavailableException'); + } catch (ProviderUnavailableException $e) { + $this->assertSame(ProviderUnavailableException::CAUSE_UPSTREAM_SERVICE_DOWN, $e->getCause()); + } + + $this->assertNull($callService->receivedSource); + }//end testAConnectorThatCannotWithholdBodiesIsNotCalled() }//end class diff --git a/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php b/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php new file mode 100644 index 0000000000..575765d2a0 --- /dev/null +++ b/tests/Unit/Service/Integration/Providers/BrpPersonProviderResidentCountTest.php @@ -0,0 +1,210 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * @link https://conduction.nl + * + * @spec openspec/specs/integration-person-lookup/spec.md#requirement-brp-count-of-residents-at-an-address-object + */ + +declare(strict_types=1); + +namespace OCA\OpenRegister\Tests\Unit\Service\Integration\Providers; + +// phpcs:disable PEAR.Commenting.FunctionComment.Missing -- test method names say what each proves; matches BrpPersonProviderTest. +// phpcs:disable CustomSniffs.Functions.NamedParameters.RequireNamedParameters -- PHPUnit assertion helpers take positional args by convention. + +use OCA\OpenRegister\Exception\ProviderUnavailableException; +use OCA\OpenRegister\Service\Integration\AbstractIntegrationProvider; +use OCA\OpenRegister\Service\Integration\ExternalIntegrationRouter; +use OCA\OpenRegister\Service\Integration\Providers\BrpPersonProvider; +use OCP\App\IAppManager; +use OCP\IL10N; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +#[CoversClass(BrpPersonProvider::class)] +#[UsesClass(AbstractIntegrationProvider::class)] +#[UsesClass(ProviderUnavailableException::class)] +class BrpPersonProviderResidentCountTest extends TestCase { + + private const ADDRESS = '0518010000123456'; + + /** + * The router double. + * + * @var ExternalIntegrationRouter&MockObject + */ + private $router; + + /** + * Every log line the provider wrote, message and context. + * + * @var list + */ + private array $logged = []; + + private BrpPersonProvider $provider; + + protected function setUp(): void { + parent::setUp(); + $this->router = $this->createMock(ExternalIntegrationRouter::class); + $this->logged = []; + + $logger = $this->createMock(LoggerInterface::class); + foreach (['warning', 'error', 'info', 'debug', 'notice'] as $level) { + $logger->method($level)->willReturnCallback( + function (string $message, array $context = []): void { + $this->logged[] = $message . json_encode($context); + } + ); + } + + $this->provider = new BrpPersonProvider( + $this->router, + $this->createMock(IAppManager::class), + $this->createMock(IL10N::class), + $logger + ); + }//end setUp() + + public function testItCountsTheResidentsAndAnswersNothingAboutThem(): void { + $this->router->method('sourceConfiguration')->willReturn(['residentCount' => true]); + $this->router->expects($this->once()) + ->method('callWithMeta') + ->with( + $this->identicalTo($this->provider), + 'POST', + 'personen', + $this->callback( + static function (array $options): bool { + return $options['body'] === [ + 'type' => 'ZoekMetAdresseerbaarObjectIdentificatie', + 'adresseerbaarObjectIdentificatie' => self::ADDRESS, + 'fields' => ['burgerservicenummer'], + ] && $options['withholdBodies'] === true; + } + ) + ) + ->willReturn( + [ + 'body' => [ + 'type' => 'ZoekMetAdresseerbaarObjectIdentificatie', + 'personen' => [ + ['burgerservicenummer' => '999993653'], + ['burgerservicenummer' => '999990627'], + ['burgerservicenummer' => '999991723'], + ], + ], + 'meta' => ['correlationId' => 'c-1', 'durationMs' => 42, 'status' => 200, 'headers' => ['X-Correlation-ID' => 'c-1']], + ] + ); + + $answer = $this->provider->countResidentsAtAddressObject(' ' . self::ADDRESS . ' '); + + $this->assertSame(['total' => 3, 'meta' => ['correlationId' => 'c-1', 'durationMs' => 42, 'status' => 200]], $answer); + $this->assertStringNotContainsString('9999', (string)json_encode($answer)); + }//end testItCountsTheResidentsAndAnswersNothingAboutThem() + + public function testNoOneRegisteredIsACountOfZero(): void { + $this->router->method('sourceConfiguration')->willReturn(['residentCount' => true]); + $this->router->method('callWithMeta')->willReturn(['body' => ['personen' => []], 'meta' => []]); + + $answer = $this->provider->countResidentsAtAddressObject(self::ADDRESS); + + $this->assertSame(0, $answer['total']); + $this->assertArrayNotHasKey('unavailable', $answer); + }//end testNoOneRegisteredIsACountOfZero() + + /** + * Only the boolean true allows the count. + * + * @return array}> + */ + public static function settingsThatDoNotAllowTheCount(): array { + return [ + 'absent' => [[]], + 'false' => [['residentCount' => false]], + 'the string true' => [['residentCount' => 'true']], + 'one' => [['residentCount' => 1]], + ]; + }//end settingsThatDoNotAllowTheCount() + + /** + * @dataProvider settingsThatDoNotAllowTheCount + * + * @param array $configuration The source configuration. + * + * @return void + */ + public function testWithoutTheSettingTheRegistryIsNotAsked(array $configuration): void { + $this->router->method('sourceConfiguration')->willReturn($configuration); + $this->router->expects($this->never())->method('callWithMeta'); + + $this->assertSame( + ['unavailable' => true, 'cause' => 'resident-count-not-enabled'], + $this->provider->countResidentsAtAddressObject(self::ADDRESS) + ); + }//end testWithoutTheSettingTheRegistryIsNotAsked() + + public function testAMalformedIdIsRefusedWithoutAnyCall(): void { + $this->router->expects($this->never())->method('sourceConfiguration'); + $this->router->expects($this->never())->method('callWithMeta'); + + foreach (['', '12345', '0518 0100 0012 3456', '051801000012345X', '05180100001234567'] as $id) { + $this->assertSame( + ['unavailable' => true, 'cause' => 'invalid-identifier'], + $this->provider->countResidentsAtAddressObject($id), + $id + ); + } + }//end testAMalformedIdIsRefusedWithoutAnyCall() + + public function testAMissingSourceDegrades(): void { + $this->router->method('sourceConfiguration')->willThrowException( + new ProviderUnavailableException(message: 'missing', cause: ProviderUnavailableException::CAUSE_OPENCONNECTOR_SOURCE_MISSING) + ); + + $this->assertSame( + ['unavailable' => true, 'cause' => 'openconnector-source-missing'], + $this->provider->countResidentsAtAddressObject(self::ADDRESS) + ); + }//end testAMissingSourceDegrades() + + public function testAnUnreachableRegistryDegrades(): void { + $this->router->method('sourceConfiguration')->willReturn(['residentCount' => true]); + $this->router->method('callWithMeta')->willThrowException( + new ProviderUnavailableException(message: 'down', cause: ProviderUnavailableException::CAUSE_UPSTREAM_SERVICE_DOWN) + ); + + $this->assertSame( + ['unavailable' => true, 'cause' => 'upstream-service-down'], + $this->provider->countResidentsAtAddressObject(self::ADDRESS) + ); + }//end testAnUnreachableRegistryDegrades() + + public function testAnUnexpectedFailureIsLoggedWithoutTheAddress(): void { + $this->router->method('sourceConfiguration')->willReturn(['residentCount' => true]); + $this->router->method('callWithMeta')->willThrowException(new \RuntimeException('POST personen ' . self::ADDRESS . ' failed')); + + $answer = $this->provider->countResidentsAtAddressObject(self::ADDRESS); + + $this->assertSame(['unavailable' => true, 'cause' => 'upstream-service-down'], $answer); + $this->assertCount(1, $this->logged); + $this->assertStringNotContainsString(self::ADDRESS, implode("\n", $this->logged)); + }//end testAnUnexpectedFailureIsLoggedWithoutTheAddress() +}//end class