From f4c0712d3cd0bbbf0eb95da2508c1e32f6a1b857 Mon Sep 17 00:00:00 2001 From: federico Date: Thu, 16 Oct 2025 15:40:35 +0100 Subject: [PATCH] ACL-75 payments providers search POST endpoint --- CLAUDE.md | 91 ++++++++++ config/bindings.php | 6 + src/Constants/Endpoints.php | 1 + .../AuthorizationFlowConfiguration.php | 105 ++++++++++++ .../PaymentsProvider/PaymentsProvider.php | 143 ++++++++++++++++ .../PaymentsProvider/SearchCapabilities.php | 86 ++++++++++ .../SearchProvidersRequest.php | 136 +++++++++++++++ .../SearchProvidersRequestBuilder.php | 45 +++++ src/Interfaces/Api/PaymentsApiInterface.php | 11 ++ ...uthorizationFlowConfigurationInterface.php | 42 +++++ .../PaymentsProviderInterface.php | 56 ++++++ .../SearchCapabilitiesInterface.php | 36 ++++ ...SearchProvidersRequestBuilderInterface.php | 23 +++ .../SearchProvidersRequestInterface.php | 53 ++++++ src/Services/Api/PaymentsApi.php | 21 +++ src/Services/Client/Client.php | 29 ++++ .../PaymentsProvidersSearchTest.php | 160 ++++++++++++++++++ 17 files changed, 1044 insertions(+) create mode 100644 CLAUDE.md create mode 100644 src/Entities/PaymentsProvider/AuthorizationFlowConfiguration.php create mode 100644 src/Entities/PaymentsProvider/PaymentsProvider.php create mode 100644 src/Entities/PaymentsProvider/SearchCapabilities.php create mode 100644 src/Entities/PaymentsProvider/SearchProvidersRequest.php create mode 100644 src/Entities/PaymentsProvider/SearchProvidersRequestBuilder.php create mode 100644 src/Interfaces/PaymentsProvider/AuthorizationFlowConfigurationInterface.php create mode 100644 src/Interfaces/PaymentsProvider/PaymentsProviderInterface.php create mode 100644 src/Interfaces/PaymentsProvider/SearchCapabilitiesInterface.php create mode 100644 src/Interfaces/PaymentsProvider/SearchProvidersRequestBuilderInterface.php create mode 100644 src/Interfaces/PaymentsProvider/SearchProvidersRequestInterface.php create mode 100644 tests/integration/PaymentsProvidersSearchTest.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..76d3355e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Testing +- `composer run integration-tests` - Run integration tests using Pest +- `composer run acceptance-tests` - Run acceptance tests using Pest +- `vendor/bin/pest --test-directory tests/integration` - Run integration tests directly +- `vendor/bin/pest --test-directory tests/acceptance` - Run acceptance tests directly + +### Code Quality +- `composer run analyse` - Run PHPStan static analysis (level 9) +- `composer run cs-fix` - Run PHP CS Fixer for code style fixes +- `composer run checks` - Run all checks (analysis, code style, and all tests) + +### PHPUnit Configuration +- Integration and acceptance tests use Pest framework +- Test configuration in `phpunit.xml.dist` +- Test helpers available in `tests/acceptance/Pest.php` and `tests/integration/Pest.php` + +## Architecture Overview + +### Core Client Structure +The library follows a factory-based architecture with dependency injection: + +- **Client Entry Point**: `TrueLayer\Client::configure()` returns a `ClientConfigInterface` for fluent configuration +- **Client Factory**: `ClientFactory` creates the main `Client` instance with all dependencies +- **API Client**: Decorated with middleware for authentication, signing, retries, and idempotency +- **Entity Factory**: Creates domain entities from API responses +- **API Factory**: Creates API service instances (payments, payouts, merchant accounts, webhooks) + +### Key Components + +#### API Client Decorators (Applied in Order) +1. `AccessTokenDecorator` - Handles OAuth2 authentication +2. `ExponentialBackoffDecorator` - Implements retry logic with backoff +3. `SigningDecorator` - Signs requests using TrueLayer's signing library +4. `IdempotencyKeyDecorator` - Manages idempotency keys for safe retries +5. `TLAgentDecorator` - Adds user agent headers + +#### Domain Entities +- **Payments**: Payment creation, retrieval, authorization flows, refunds +- **Payouts**: External account payouts, business account payouts, payment source refunds +- **Merchant Accounts**: Account management and balance retrieval +- **Webhooks**: Event handling with signature verification +- **Account Identifiers**: Support for IBAN, SCAN, NRB, BBAN account types + +#### Builder Pattern Usage +The library extensively uses builders for complex entity creation: +- `BeneficiaryBuilder` for payment beneficiaries +- `PaymentMethodBuilder` for payment methods +- `ProviderSelectionBuilder` for provider filtering +- `SchemeSelectionBuilder` for payment scheme selection + +### Configuration and Environment +- Supports both sandbox and production environments +- Configurable via environment variables or programmatic setup +- Optional caching support for OAuth tokens (PSR-16 compatible) +- Requires PSR-18 HTTP client implementation + +### Security Features +- Request signing using private keys (EC512 supported) +- Webhook signature verification +- OAuth2 client credentials flow +- Encrypted token caching when cache is enabled + +### Constants and Enums +Located in `src/Constants/` directory: +- Payment statuses, currencies, countries +- Webhook event types +- API endpoints and HTTP methods +- Account identifier types and schemes + +### Error Handling +Custom exceptions in `src/Exceptions/`: +- `ApiResponseUnsuccessfulException` for API errors +- `SignerException` for signing failures +- `WebhookVerificationFailedException` for webhook security +- All extend base `Exception` class + +### API Reference +- Use `Payments_API_V3_specs` file as the Payments API specs reference (as noted in CLAUDE.local.md) +- Comprehensive examples available in README.md for all major operations + +## Testing Notes +- Acceptance tests require environment variables in `.env` file +- Integration tests use mocked HTTP responses +- Test helpers available for common operations like payment creation +- Mock responses and test utilities in `tests/integration/Mocks/` \ No newline at end of file diff --git a/config/bindings.php b/config/bindings.php index 9af12f58..7104eeca 100644 --- a/config/bindings.php +++ b/config/bindings.php @@ -117,5 +117,11 @@ Interfaces\SignupPlus\SignupPlusUserDataRequestInterface::class => Entities\SignupPlus\SignupPlusUserDataRequest::class, Interfaces\SignupPlus\SignupPlusUserDataRetrievedInterface::class => Entities\SignupPlus\SignupPlusUserDataRetrieved::class, + Interfaces\PaymentsProvider\SearchProvidersRequestBuilderInterface::class => Entities\PaymentsProvider\SearchProvidersRequestBuilder::class, + Interfaces\PaymentsProvider\SearchProvidersRequestInterface::class => Entities\PaymentsProvider\SearchProvidersRequest::class, + Interfaces\PaymentsProvider\AuthorizationFlowConfigurationInterface::class => Entities\PaymentsProvider\AuthorizationFlowConfiguration::class, + Interfaces\PaymentsProvider\SearchCapabilitiesInterface::class => Entities\PaymentsProvider\SearchCapabilities::class, + Interfaces\PaymentsProvider\PaymentsProviderInterface::class => Entities\PaymentsProvider\PaymentsProvider::class, + Interfaces\RequestOptionsInterface::class => Entities\RequestOptions::class, ]; diff --git a/src/Constants/Endpoints.php b/src/Constants/Endpoints.php index adc713a1..1fb2f4c5 100644 --- a/src/Constants/Endpoints.php +++ b/src/Constants/Endpoints.php @@ -29,6 +29,7 @@ class Endpoints public const PAYMENTS_REFUNDS_RETRIEVE_ALL = '/v3/payments/{id}/refunds'; public const PAYMENTS_REFUNDS_RETRIEVE = '/v3/payments/{id}/refunds/{refund_id}'; public const PAYMENTS_PROVIDER_RETURN = '/v3/payments-provider-return'; + public const PAYMENTS_PROVIDERS_SEARCH = '/v3/payments-providers/search'; public const MERCHANT_ACCOUNTS = '/v3/merchant-accounts'; public const TRANSACTIONS = '/v3/merchant-accounts/{id}/transactions'; diff --git a/src/Entities/PaymentsProvider/AuthorizationFlowConfiguration.php b/src/Entities/PaymentsProvider/AuthorizationFlowConfiguration.php new file mode 100644 index 00000000..40e8c84b --- /dev/null +++ b/src/Entities/PaymentsProvider/AuthorizationFlowConfiguration.php @@ -0,0 +1,105 @@ +|\stdClass + */ + protected $redirect; + + /** + * @var array|\stdClass + */ + protected $providerSelection; + + /** + * @var array + */ + protected array $form; + + /** + * @var array + */ + protected array $consent; + + /** + * @var array + */ + protected array $arrayFields = [ + 'redirect', + 'provider_selection', + 'form', + 'consent', + ]; + + /** + * @return $this + */ + public function redirect(): self + { + $this->redirect = (object) []; + + return $this; + } + + /** + * @return $this + */ + public function providerSelection(): self + { + $this->providerSelection = (object) []; + + return $this; + } + + /** + * @param string $size + * + * @return $this + */ + public function providerSelectionWithIcon(string $size): self + { + $this->providerSelection = [ + 'icon' => [ + 'size' => $size, + ], + ]; + + return $this; + } + + /** + * @param string[] $inputTypes + * + * @return $this + */ + public function form(array $inputTypes): self + { + $this->form = [ + 'input_types' => $inputTypes, + ]; + + return $this; + } + + /** + * @param string $requirements + * + * @return $this + */ + public function consent(string $requirements): self + { + $this->consent = [ + 'requirements' => $requirements, + ]; + + return $this; + } +} diff --git a/src/Entities/PaymentsProvider/PaymentsProvider.php b/src/Entities/PaymentsProvider/PaymentsProvider.php new file mode 100644 index 00000000..bcecea5b --- /dev/null +++ b/src/Entities/PaymentsProvider/PaymentsProvider.php @@ -0,0 +1,143 @@ + + */ + protected array $arrayFields = [ + 'id', + 'display_name', + 'icon_uri', + 'logo_uri', + 'bg_color', + 'country_code', + 'swift_code', + 'capabilities', + 'bin_ranges', + ]; + + /** + * @return string + */ + public function getId(): string + { + return $this->id; + } + + /** + * @return string|null + */ + public function getDisplayName(): ?string + { + return $this->displayName; + } + + /** + * @return string|null + */ + public function getIconUri(): ?string + { + return $this->iconUri; + } + + /** + * @return string|null + */ + public function getLogoUri(): ?string + { + return $this->logoUri; + } + + /** + * @return string|null + */ + public function getBgColor(): ?string + { + return $this->bgColor; + } + + /** + * @return string|null + */ + public function getCountryCode(): ?string + { + return $this->countryCode; + } + + /** + * @return string|null + */ + public function getSwiftCode(): ?string + { + return $this->swiftCode; + } + + /** + * @return mixed[] + */ + public function getCapabilities(): array + { + return $this->capabilities; + } + + /** + * @return mixed[]|null + */ + public function getBinRanges(): ?array + { + return $this->binRanges; + } +} diff --git a/src/Entities/PaymentsProvider/SearchCapabilities.php b/src/Entities/PaymentsProvider/SearchCapabilities.php new file mode 100644 index 00000000..16ebb062 --- /dev/null +++ b/src/Entities/PaymentsProvider/SearchCapabilities.php @@ -0,0 +1,86 @@ + + */ + protected array $payments; + + /** + * @var array + */ + protected array $mandates; + + /** + * @var array + */ + protected array $arrayFields = [ + 'payments', + 'mandates', + ]; + + /** + * @return $this + */ + public function payments(): self + { + if (!isset($this->payments)) { + $this->payments = []; + } + + return $this; + } + + /** + * @return $this + */ + public function bankTransfer(): self + { + $this->payments(); + $this->payments['bank_transfer'] = (object) []; + + return $this; + } + + /** + * @return $this + */ + public function mandates(): self + { + if (!isset($this->mandates)) { + $this->mandates = []; + } + + return $this; + } + + /** + * @return $this + */ + public function vrpCommercial(): self + { + $this->mandates(); + $this->mandates['vrp_commercial'] = (object) []; + + return $this; + } + + /** + * @return $this + */ + public function vrpSweeping(): self + { + $this->mandates(); + $this->mandates['vrp_sweeping'] = (object) []; + + return $this; + } +} diff --git a/src/Entities/PaymentsProvider/SearchProvidersRequest.php b/src/Entities/PaymentsProvider/SearchProvidersRequest.php new file mode 100644 index 00000000..dd717bc0 --- /dev/null +++ b/src/Entities/PaymentsProvider/SearchProvidersRequest.php @@ -0,0 +1,136 @@ + + */ + protected array $authorizationFlow; + + /** + * @var SearchCapabilitiesInterface + */ + protected SearchCapabilitiesInterface $capabilities; + + /** + * @var string[] + */ + protected array $countries; + + /** + * @var string[] + */ + protected array $currencies; + + /** + * @var string[] + */ + protected array $customerSegments; + + /** + * @var string + */ + protected string $releaseChannel; + + /** + * @var array + */ + protected array $casts = [ + 'capabilities' => SearchCapabilitiesInterface::class, + ]; + + /** + * @var array + */ + protected array $arrayFields = [ + 'authorization_flow', + 'capabilities', + 'countries', + 'currencies', + 'customer_segments', + 'release_channel', + ]; + + /** + * @param AuthorizationFlowConfigurationInterface $configuration + * + * @return $this + */ + public function authorizationFlowConfiguration(AuthorizationFlowConfigurationInterface $configuration): self + { + $this->authorizationFlow = [ + 'configuration' => $configuration->toArray(), + ]; + + return $this; + } + + /** + * @param SearchCapabilitiesInterface $capabilities + * + * @return $this + */ + public function capabilities(SearchCapabilitiesInterface $capabilities): self + { + $this->capabilities = $capabilities; + + return $this; + } + + /** + * @param string[] $countries + * + * @return $this + */ + public function countries(array $countries): self + { + $this->countries = $countries; + + return $this; + } + + /** + * @param string[] $currencies + * + * @return $this + */ + public function currencies(array $currencies): self + { + $this->currencies = $currencies; + + return $this; + } + + /** + * @param string[] $customerSegments + * + * @return $this + */ + public function customerSegments(array $customerSegments): self + { + $this->customerSegments = $customerSegments; + + return $this; + } + + /** + * @param string $releaseChannel + * + * @return $this + */ + public function releaseChannel(string $releaseChannel): self + { + $this->releaseChannel = $releaseChannel; + + return $this; + } +} diff --git a/src/Entities/PaymentsProvider/SearchProvidersRequestBuilder.php b/src/Entities/PaymentsProvider/SearchProvidersRequestBuilder.php new file mode 100644 index 00000000..f7c929e4 --- /dev/null +++ b/src/Entities/PaymentsProvider/SearchProvidersRequestBuilder.php @@ -0,0 +1,45 @@ +entityFactory->make(SearchProvidersRequestInterface::class); + } + + /** + * @throws InvalidArgumentException + * + * @return AuthorizationFlowConfigurationInterface + */ + public function authorizationFlowConfiguration(): AuthorizationFlowConfigurationInterface + { + return $this->entityFactory->make(AuthorizationFlowConfigurationInterface::class); + } + + /** + * @throws InvalidArgumentException + * + * @return SearchCapabilitiesInterface + */ + public function capabilities(): SearchCapabilitiesInterface + { + return $this->entityFactory->make(SearchCapabilitiesInterface::class); + } +} diff --git a/src/Interfaces/Api/PaymentsApiInterface.php b/src/Interfaces/Api/PaymentsApiInterface.php index c189e933..b11a6587 100644 --- a/src/Interfaces/Api/PaymentsApiInterface.php +++ b/src/Interfaces/Api/PaymentsApiInterface.php @@ -104,4 +104,15 @@ public function retrieveRefund(string $paymentId, string $refundId): array; * @return mixed[] */ public function retrieveRefunds(string $paymentId): array; + + /** + * @param mixed[] $searchRequest + * + * @throws SignerException + * @throws ApiRequestJsonSerializationException + * @throws ApiResponseUnsuccessfulException + * + * @return mixed[] + */ + public function searchProviders(array $searchRequest): array; } diff --git a/src/Interfaces/PaymentsProvider/AuthorizationFlowConfigurationInterface.php b/src/Interfaces/PaymentsProvider/AuthorizationFlowConfigurationInterface.php new file mode 100644 index 00000000..c4923db5 --- /dev/null +++ b/src/Interfaces/PaymentsProvider/AuthorizationFlowConfigurationInterface.php @@ -0,0 +1,42 @@ +request() + ->uri(Endpoints::PAYMENTS_PROVIDERS_SEARCH) + ->payload($searchRequest) + ->post(); + + return isset($response['items']) && \is_array($response['items']) + ? $response['items'] + : []; + } } diff --git a/src/Services/Client/Client.php b/src/Services/Client/Client.php index 5d80644e..4f0d1ac0 100644 --- a/src/Services/Client/Client.php +++ b/src/Services/Client/Client.php @@ -37,6 +37,8 @@ use TrueLayer\Interfaces\Remitter\RemitterVerification\RemitterVerificationBuilderInterface; use TrueLayer\Interfaces\RequestOptionsInterface; use TrueLayer\Interfaces\Payment\Scheme\SchemeSelectionBuilderInterface; +use TrueLayer\Interfaces\PaymentsProvider\PaymentsProviderInterface; +use TrueLayer\Interfaces\PaymentsProvider\SearchProvidersRequestBuilderInterface; use TrueLayer\Interfaces\SignupPlus\SignupPlusBuilderInterface; use TrueLayer\Interfaces\UserInterface; use TrueLayer\Interfaces\Webhook\WebhookInterface; @@ -435,4 +437,31 @@ public function signupPlus(): SignupPlusBuilderInterface { return $this->entityFactory->make(SignupPlusBuilderInterface::class); } + + /** + * @throws InvalidArgumentException + * + * @return SearchProvidersRequestBuilderInterface + */ + public function searchProvidersRequest(): SearchProvidersRequestBuilderInterface + { + return $this->entityFactory->make(SearchProvidersRequestBuilderInterface::class); + } + + /** + * @param array $searchRequest + * + * @throws InvalidArgumentException + * @throws SignerException + * @throws ApiRequestJsonSerializationException + * @throws ApiResponseUnsuccessfulException + * + * @return PaymentsProviderInterface[] + */ + public function searchPaymentsProviders(array $searchRequest): array + { + $data = $this->apiFactory->paymentsApi()->searchProviders($searchRequest); + + return $this->entityFactory->makeMany(PaymentsProviderInterface::class, $data); + } } diff --git a/tests/integration/PaymentsProvidersSearchTest.php b/tests/integration/PaymentsProvidersSearchTest.php new file mode 100644 index 00000000..3e08cafa --- /dev/null +++ b/tests/integration/PaymentsProvidersSearchTest.php @@ -0,0 +1,160 @@ + [ + [ + 'id' => 'mock-payments-gb-redirect', + 'display_name' => 'Mock Bank', + 'icon_uri' => 'https://example.com/icon.svg', + 'logo_uri' => 'https://example.com/logo.svg', + 'bg_color' => '#000000', + 'country_code' => Countries::GB, + 'capabilities' => [ + 'payments' => [ + 'bank_transfer' => (object) [], + ], + ], + ], + ], + ])); + + $client = \client([$mockResponse]); + + $config = $client->searchProvidersRequest()->authorizationFlowConfiguration(); + $config->redirect(); + + $request = $client->searchProvidersRequest()->create(); + $request->authorizationFlowConfiguration($config); + + $providers = $client->searchPaymentsProviders($request->toArray()); + + expect($providers)->toBeArray(); + expect($providers)->toHaveCount(1); + expect($providers[0]->getId())->toBe('mock-payments-gb-redirect'); + expect($providers[0]->getDisplayName())->toBe('Mock Bank'); + expect($providers[0]->getCountryCode())->toBe(Countries::GB); +}); + +it('searches payment providers with full configuration', function () { + $mockResponse = new Response(200, [], \json_encode([ + 'items' => [ + [ + 'id' => 'mock-payments-gb-redirect', + 'display_name' => 'Mock Bank', + 'icon_uri' => 'https://example.com/icon.svg', + 'logo_uri' => 'https://example.com/logo.svg', + 'bg_color' => '#000000', + 'country_code' => Countries::GB, + 'swift_code' => 'MOCKGB01XXX', + 'capabilities' => [ + 'payments' => [ + 'bank_transfer' => (object) [], + ], + ], + 'bin_ranges' => [ + ['min' => '123456', 'max' => '123499'], + ], + ], + ], + ])); + + $client = \client([$mockResponse]); + + $config = $client->searchProvidersRequest()->authorizationFlowConfiguration(); + $config->redirect(); + $config->providerSelection(); + $config->form(['text', 'select']); + $config->consent('supported'); + + $capabilities = $client->searchProvidersRequest()->capabilities(); + $capabilities->bankTransfer(); + + $request = $client->searchProvidersRequest()->create(); + $request->authorizationFlowConfiguration($config); + $request->capabilities($capabilities); + $request->countries([Countries::GB]); + $request->currencies([Currencies::GBP]); + $request->customerSegments([CustomerSegments::RETAIL]); + $request->releaseChannel(ReleaseChannels::GENERAL_AVAILABILITY); + + $providers = $client->searchPaymentsProviders($request->toArray()); + + expect($providers)->toBeArray(); + expect($providers)->toHaveCount(1); + expect($providers[0]->getId())->toBe('mock-payments-gb-redirect'); + expect($providers[0]->getDisplayName())->toBe('Mock Bank'); + expect($providers[0]->getSwiftCode())->toBe('MOCKGB01XXX'); + expect($providers[0]->getBinRanges())->toBeArray(); +}); + +it('sends correct payload for search providers request', function () { + $mockResponse = new Response(200, [], \json_encode(['items' => []])); + $client = \client([$mockResponse]); + + $config = $client->searchProvidersRequest()->authorizationFlowConfiguration(); + $config->redirect(); + $config->providerSelectionWithIcon('large'); + $config->form(['text']); + + $capabilities = $client->searchProvidersRequest()->capabilities(); + $capabilities->bankTransfer(); + + $request = $client->searchProvidersRequest()->create(); + $request->authorizationFlowConfiguration($config); + $request->capabilities($capabilities); + $request->countries([Countries::GB, Countries::FR]); + $request->currencies([Currencies::GBP, Currencies::EUR]); + $request->customerSegments([CustomerSegments::RETAIL, CustomerSegments::BUSINESS]); + $request->releaseChannel(ReleaseChannels::PUBLIC_BETA); + + $client->searchPaymentsProviders($request->toArray()); + + $payload = \getRequestPayload(1); + + expect($payload)->toHaveKey('authorization_flow'); + expect($payload['authorization_flow']['configuration'])->toHaveKey('redirect'); + expect($payload['authorization_flow']['configuration'])->toHaveKey('provider_selection'); + expect($payload['authorization_flow']['configuration']['provider_selection'])->toHaveKey('icon'); + expect($payload['authorization_flow']['configuration']['provider_selection']['icon']['size'])->toBe('large'); + expect($payload['authorization_flow']['configuration'])->toHaveKey('form'); + expect($payload['authorization_flow']['configuration']['form']['input_types'])->toBe(['text']); + expect($payload['capabilities']['payments'])->toHaveKey('bank_transfer'); + expect($payload['countries'])->toBe([Countries::GB, Countries::FR]); + expect($payload['currencies'])->toBe([Currencies::GBP, Currencies::EUR]); + expect($payload['customer_segments'])->toBe([CustomerSegments::RETAIL, CustomerSegments::BUSINESS]); + expect($payload['release_channel'])->toBe(ReleaseChannels::PUBLIC_BETA); +}); + +it('searches with mandates capabilities', function () { + $mockResponse = new Response(200, [], \json_encode(['items' => []])); + $client = \client([$mockResponse]); + + $config = $client->searchProvidersRequest()->authorizationFlowConfiguration(); + $config->redirect(); + + $capabilities = $client->searchProvidersRequest()->capabilities(); + $capabilities->vrpCommercial(); + $capabilities->vrpSweeping(); + + $request = $client->searchProvidersRequest()->create(); + $request->authorizationFlowConfiguration($config); + $request->capabilities($capabilities); + + $client->searchPaymentsProviders($request->toArray()); + + $payload = \getRequestPayload(1); + + expect($payload['capabilities']['mandates'])->toBeArray(); + expect($payload['capabilities']['mandates'])->toHaveKey('vrp_commercial'); + expect($payload['capabilities']['mandates'])->toHaveKey('vrp_sweeping'); +});