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