From 0ab4af04465c235ce34daeec58c3a7ff78a23d4b Mon Sep 17 00:00:00 2001 From: Jannis Noordmann Date: Tue, 4 Aug 2026 10:31:52 +0200 Subject: [PATCH 1/6] feat: implement oauth functionality --- .../Exceptions/CleverReachAuthException.php | 11 + src/Auth/OAuthHelper.php | 231 ++++++++++++++++++ src/Auth/Storage/FileTokenStorage.php | 54 ++++ src/Auth/Storage/MemoryTokenStorage.php | 24 ++ src/Auth/Storage/TokenStorageInterface.php | 16 ++ src/Auth/TokenProviderInterface.php | 18 ++ src/Auth/Tokens.php | 102 ++++++++ src/CleverReachClient.php | 27 +- src/Http/ApiRequestor.php | 46 +++- tests/Auth/OAuthHelperTest.php | 44 ++++ tests/Http/ApiRequestorTest.php | 94 ++++++- 11 files changed, 646 insertions(+), 21 deletions(-) create mode 100644 src/Auth/Exceptions/CleverReachAuthException.php create mode 100644 src/Auth/OAuthHelper.php create mode 100644 src/Auth/Storage/FileTokenStorage.php create mode 100644 src/Auth/Storage/MemoryTokenStorage.php create mode 100644 src/Auth/Storage/TokenStorageInterface.php create mode 100644 src/Auth/TokenProviderInterface.php create mode 100644 src/Auth/Tokens.php create mode 100644 tests/Auth/OAuthHelperTest.php diff --git a/src/Auth/Exceptions/CleverReachAuthException.php b/src/Auth/Exceptions/CleverReachAuthException.php new file mode 100644 index 0000000..96d8d6f --- /dev/null +++ b/src/Auth/Exceptions/CleverReachAuthException.php @@ -0,0 +1,11 @@ +storage = $storage ?? new MemoryTokenStorage(); + + $host = parse_url($baseUri, PHP_URL_HOST) ?? 'rest.cleverreach.com'; + $scheme = parse_url($baseUri, PHP_URL_SCHEME) ?? 'https'; + $this->authBaseUrl = $scheme.'://'.$host.'/oauth'; + + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + /** + * @param array $scopes + */ + public function getAuthorizationUrl(?string $state = null, array $scopes = []): string { + if ($state === null || trim($state) === '') { + throw new \InvalidArgumentException('State must not be null or empty.'); + } + + $params = [ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'response_type' => 'code', + 'grant' => 'basic', + 'state' => $state, + ]; + + if ($scopes !== []) { + $params['scope'] = implode(' ', $scopes); + } + + return $this->authBaseUrl.'/authorize.php?'.http_build_query($params); + } + + /** + * Exchanges the authorization code received from the callback for an access token. + * The resulting token is automatically stored in the configured token storage. + */ + public function exchangeCodeForToken(string $code, string $receivedState, string $expectedState): Tokens { + $expectedState = trim($expectedState); + $receivedState = trim($receivedState); + + if ($expectedState === '' || $receivedState === '') { + throw new CleverReachAuthException('Invalid state: state must not be empty.'); + } + + if (!hash_equals($expectedState, $receivedState)) { + throw new CleverReachAuthException('Invalid state: state mismatch.'); + } + + return $this->doTokenRequest([ + 'grant_type' => 'authorization_code', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $this->redirectUri, + 'code' => $code, + ]); + } + + /** + * Refreshes the access token using the stored refresh token, or a specific provided token. + * The new token is automatically stored in the configured token storage. + * + * @throws CleverReachAuthException If no refresh token is available + */ + public function refreshAccessToken(?string $refreshToken = null): Tokens { + $tokenToUse = $refreshToken ?? $this->storage->get()?->getRefreshToken(); + + if ($tokenToUse === null) { + throw new CleverReachAuthException('No refresh token available.'); + } + + return $this->doTokenRequest([ + 'grant_type' => 'refresh_token', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'refresh_token' => $tokenToUse, + ]); + } + + /** + * Internal generic method to fulfill TokenProviderInterface. + * Always ensures returning a valid access token (refreshes if necessary). + * + * @param bool $forceRefresh If true, skips the expiry check and forces a refresh via API + */ + public function getAccessToken(bool $forceRefresh = false): string { + $tokens = $this->storage->get(); + + if ($tokens === null) { + throw new CleverReachAuthException('No access token available. Authorization required.'); + } + + if ($forceRefresh || $tokens->isExpired()) { + $tokens = $this->refreshAccessToken($tokens->getRefreshToken()); + } + + return $tokens->getAccessToken(); + } + + public function clearStoredTokens(): void { + $this->storage->delete(); + } + + /** + * Revokes a specific token at the CleverReach backend. + * Use this when logging out users. + */ + public function revokeToken(string $token): void { + $request = $this->requestFactory + ->createRequest('DELETE', $this->authBaseUrl.'/token') + ->withHeader('Authorization', 'Bearer '.$token) + ; + + try { + $this->httpClient->sendRequest($request); + $this->clearStoredTokens(); + } catch (ClientExceptionInterface $e) { + throw new CleverReachAuthException('HTTP communication during token revocation failed.', null, null, $e); + } + } + + /** + * Executes an OAuth token request directly using the embedded PSR-18 client, + * so that the logic remains strictly separated from `ApiRequestor`. + * + * @param array $bodyParams + */ + private function doTokenRequest(array $bodyParams): Tokens { + $request = $this->requestFactory + ->createRequest('POST', $this->authBaseUrl.'/token.php') + ->withHeader('Accept', 'application/json') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($this->streamFactory->createStream(http_build_query($bodyParams))) + ; + + try { + $response = $this->httpClient->sendRequest($request); + $statusCode = $response->getStatusCode(); + $rawBody = (string) $response->getBody(); + + if ($statusCode >= 400 && $statusCode < 500 && ($bodyParams['grant_type'] ?? '') === 'refresh_token') { + // If a refresh request fails with a 4xx error (e.g. invalid grant), + // the refresh token is permanently broken. Clear it to avoid infinite loops. + $this->clearStoredTokens(); + } + + try { + $decoded = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + if ($statusCode < 200 || $statusCode >= 300) { + throw new CleverReachAuthException('OAuth request failed with HTTP '.$statusCode.' and non-JSON body.'); + } + + throw $e; + } + + if ($statusCode < 200 || $statusCode >= 300) { + if (is_array($decoded)) { + $errorMsg = $decoded['error_description'] ?? $decoded['error'] ?? 'API request failed'; + } else { + $errorMsg = 'OAuth request failed with HTTP '.$statusCode; + } + + throw new CleverReachAuthException('OAuth request failed: '.$errorMsg); + } + + if (!is_array($decoded) || !isset($decoded['access_token'])) { + throw new CleverReachAuthException('Invalid API response: missing access_token.'); + } + + $scopes = []; + if (isset($decoded['scope'])) { + if (is_array($decoded['scope'])) { + $scopes = $decoded['scope']; + } elseif (is_string($decoded['scope'])) { + $scopes = explode(' ', $decoded['scope']); + } + } + + $tokens = new Tokens( + (string) $decoded['access_token'], + isset($decoded['refresh_token']) ? (string) $decoded['refresh_token'] : null, + isset($decoded['expires_in']) ? time() + (int) $decoded['expires_in'] : null, + $scopes + ); + + $this->storage->set($tokens); + + return $tokens; + } catch (\JsonException $e) { + throw new CleverReachAuthException('Failed to decode CleverReach OAuth response.', null, null, $e); + } catch (ClientExceptionInterface $e) { + throw new CleverReachAuthException('HTTP communication during OAuth failed.', null, null, $e); + } + } +} diff --git a/src/Auth/Storage/FileTokenStorage.php b/src/Auth/Storage/FileTokenStorage.php new file mode 100644 index 0000000..b8bbe84 --- /dev/null +++ b/src/Auth/Storage/FileTokenStorage.php @@ -0,0 +1,54 @@ +filePath)) { + return null; + } + + $content = file_get_contents($this->filePath); + if ($content === false || $content === '') { + return null; + } + + try { + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + if (!is_array($data)) { + return null; + } + + return Tokens::fromArray($data); + } catch (\InvalidArgumentException|\JsonException) { + return null; + } + } + + public function set(Tokens $tokens): void { + $data = json_encode($tokens->toArray(), JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); + + $dir = dirname($this->filePath); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + file_put_contents($this->filePath, $data, LOCK_EX); + } + + public function delete(): void { + if (file_exists($this->filePath)) { + unlink($this->filePath); + } + } +} diff --git a/src/Auth/Storage/MemoryTokenStorage.php b/src/Auth/Storage/MemoryTokenStorage.php new file mode 100644 index 0000000..9637468 --- /dev/null +++ b/src/Auth/Storage/MemoryTokenStorage.php @@ -0,0 +1,24 @@ +tokens; + } + + public function set(Tokens $tokens): void { + $this->tokens = $tokens; + } + + public function delete(): void { + $this->tokens = null; + } +} diff --git a/src/Auth/Storage/TokenStorageInterface.php b/src/Auth/Storage/TokenStorageInterface.php new file mode 100644 index 0000000..271a19a --- /dev/null +++ b/src/Auth/Storage/TokenStorageInterface.php @@ -0,0 +1,16 @@ + $scopes + */ + public function __construct( + private readonly string $accessToken, + private readonly ?string $refreshToken = null, + private readonly ?int $expiresAt = null, + private readonly array $scopes = [] + ) { + if ($accessToken === '') { + throw new \InvalidArgumentException('access_token must not be empty'); + } + } + + public function getAccessToken(): string { + return $this->accessToken; + } + + public function getRefreshToken(): ?string { + return $this->refreshToken; + } + + public function getExpiresAt(): ?int { + return $this->expiresAt; + } + + /** + * @return array + */ + public function getScopes(): array { + return $this->scopes; + } + + /** + * Checks if the token possesses a specific scope. + */ + public function hasScope(string $scope): bool { + return in_array($scope, $this->scopes, true); + } + + /** + * Checks if the token is expired or will expire within the given margin. + * + * @param int $marginSeconds Safety margin in seconds + */ + public function isExpired(int $marginSeconds = 60): bool { + if ($this->expiresAt === null) { + return false; + } + + return time() >= ($this->expiresAt - $marginSeconds); + } + + /** + * @return array{access_token: string, refresh_token: ?string, expires_at: ?int, scopes: array} + */ + public function toArray(): array { + return [ + 'access_token' => $this->accessToken, + 'refresh_token' => $this->refreshToken, + 'expires_at' => $this->expiresAt, + 'scopes' => $this->scopes, + ]; + } + + /** + * @param array $data + */ + public static function fromArray(array $data): self { + if (!isset($data['access_token']) || !is_string($data['access_token']) || trim($data['access_token']) === '') { + throw new \InvalidArgumentException('access_token is missing, not a string, or empty'); + } + + if (isset($data['refresh_token']) && !is_string($data['refresh_token'])) { + throw new \InvalidArgumentException('refresh_token must be a string or null'); + } + + if (isset($data['expires_at']) && !is_int($data['expires_at'])) { + throw new \InvalidArgumentException('expires_at must be an integer or null'); + } + + $scopes = []; + if (isset($data['scopes']) && is_array($data['scopes'])) { + $scopes = array_filter($data['scopes'], static fn ($scope) => is_string($scope)); + } + + return new self( + $data['access_token'], + $data['refresh_token'] ?? null, + $data['expires_at'] ?? null, + array_values($scopes) + ); + } +} diff --git a/src/CleverReachClient.php b/src/CleverReachClient.php index ffe2e1e..55fee70 100644 --- a/src/CleverReachClient.php +++ b/src/CleverReachClient.php @@ -4,6 +4,7 @@ namespace CleverReach\SDK; +use CleverReach\SDK\Auth\TokenProviderInterface; use CleverReach\SDK\Exception\AuthenticationException; use CleverReach\SDK\Exception\CleverReachException; use CleverReach\SDK\Exception\MissingDependencyException; @@ -23,8 +24,14 @@ * * @example * ```php + * // Basic usage with a static API Token * $client = new CleverReachClient('YOUR_API_TOKEN'); * + * // Advanced usage with OAuth 2.0 flow + * $oauth = new CleverReach\SDK\Auth\OAuthHelper('CLIENT_ID', 'CLIENT_SECRET', 'https://your-domain.com/callback'); + * $client = new CleverReachClient(); + * $client->setTokenProvider($oauth); + * * // Typed service API (recommended) * $groups = $client->groups()->all(); * $receiver = $client->receivers()->get('jane@example.com'); @@ -43,21 +50,29 @@ final class CleverReachClient private ?ReceiversService $receiversService = null; public function __construct( - private readonly string $apiToken, + string $apiToken = '', string $baseUri = 'https://rest.cleverreach.com/v3/', ?ClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null ) { $this->requestor = new ApiRequestor( - apiToken: $this->apiToken, - baseUri: $baseUri, - httpClient: $httpClient, - requestFactory: $requestFactory, - streamFactory: $streamFactory + $apiToken, + $baseUri, + $httpClient, + $requestFactory, + $streamFactory ); } + /** + * Sets a custom TokenProvider (e.g., an OAuthHelper instance) which will + * be responsible for injecting a valid Bearer token into outgoing requests. + */ + public function setTokenProvider(TokenProviderInterface $tokenProvider): void { + $this->requestor->setTokenProvider($tokenProvider); + } + /** * Sends a raw request to any CleverReach API endpoint. * diff --git a/src/Http/ApiRequestor.php b/src/Http/ApiRequestor.php index 4bc6bbc..af3ce6a 100644 --- a/src/Http/ApiRequestor.php +++ b/src/Http/ApiRequestor.php @@ -4,6 +4,7 @@ namespace CleverReach\SDK\Http; +use CleverReach\SDK\Auth\TokenProviderInterface; use CleverReach\SDK\Exception\AuthenticationException; use CleverReach\SDK\Exception\CleverReachException; use CleverReach\SDK\Exception\MissingDependencyException; @@ -24,18 +25,15 @@ final class ApiRequestor implements ApiRequestorInterface private readonly RequestFactoryInterface $requestFactory; private readonly StreamFactoryInterface $streamFactory; private readonly string $baseUri; + private ?TokenProviderInterface $tokenProvider = null; public function __construct( - private readonly string $apiToken, + private readonly string $apiToken = '', string $baseUri = 'https://rest.cleverreach.com/v3/', ?ClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null ) { - if (trim($this->apiToken) === '') { - throw new AuthenticationException('API token must not be empty.'); - } - $this->baseUri = rtrim($baseUri, '/').'/'; try { @@ -65,13 +63,40 @@ public function request( string $uri, array $query = [], ?array $json = null + ): array { + return $this->doRequest($method, $uri, $query, $json, false); + } + + public function setTokenProvider(TokenProviderInterface $tokenProvider): void { + $this->tokenProvider = $tokenProvider; + } + + /** + * @param array $query + * @param null|array $json + * + * @return array|list> + */ + private function doRequest( + string $method, + string $uri, + array $query, + ?array $json, + bool $isRetrying ): array { try { - $request = $this->createRequest($method, $uri, $query, $json); + $request = $this->createRequest($method, $uri, $query, $json, $isRetrying); $response = $this->httpClient->sendRequest($request); $statusCode = $response->getStatusCode(); $rawBody = (string) $response->getBody(); + // If we get an Unauthorized response and we use a TokenProvider (OAuth), + // the token might have been invalidated server-side before its expiry date. + // Force a hard refresh and retry exactly once. + if ($statusCode === 401 && !$isRetrying && $this->tokenProvider !== null) { + return $this->doRequest($method, $uri, $query, $json, true); + } + if ($statusCode === 401) { throw new AuthenticationException( $this->buildErrorMessage($statusCode, $rawBody, 'CleverReach API request failed.'), @@ -134,7 +159,7 @@ public function request( * @param array $query * @param null|array $json */ - private function createRequest(string $method, string $uri, array $query, ?array $json): RequestInterface { + private function createRequest(string $method, string $uri, array $query, ?array $json, bool $forceTokenRefresh): RequestInterface { $url = $this->baseUri.ltrim($uri, '/'); $cleanQuery = array_filter($query, static fn (mixed $value): bool => $value !== null); @@ -145,9 +170,14 @@ private function createRequest(string $method, string $uri, array $query, ?array $request = $this->requestFactory ->createRequest($method, $url) ->withHeader('Accept', 'application/json') - ->withHeader('Authorization', 'Bearer '.$this->apiToken) ; + if ($this->tokenProvider !== null) { + $request = $request->withHeader('Authorization', 'Bearer '.$this->tokenProvider->getAccessToken($forceTokenRefresh)); + } elseif ($this->apiToken !== '') { + $request = $request->withHeader('Authorization', 'Bearer '.$this->apiToken); + } + if ($json === null) { return $request; } diff --git a/tests/Auth/OAuthHelperTest.php b/tests/Auth/OAuthHelperTest.php new file mode 100644 index 0000000..6994c68 --- /dev/null +++ b/tests/Auth/OAuthHelperTest.php @@ -0,0 +1,44 @@ +createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + + $helper = new OAuthHelper( + 'client_id', + 'client_secret', + 'https://example.com/callback', + null, + 'https://rest.cleverreach.com/v3', + $httpClient, + $requestFactory, + $streamFactory + ); + + $httpClient->expects(self::never())->method('sendRequest'); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('Invalid state: state must not be empty.'); + + $helper->exchangeCodeForToken('code123', '', ''); + } +} diff --git a/tests/Http/ApiRequestorTest.php b/tests/Http/ApiRequestorTest.php index d23dd5a..9fd2189 100644 --- a/tests/Http/ApiRequestorTest.php +++ b/tests/Http/ApiRequestorTest.php @@ -4,8 +4,12 @@ namespace CleverReach\Tests\Http; +use CleverReach\SDK\Auth\TokenProviderInterface; use CleverReach\SDK\Exception\AuthenticationException; use CleverReach\SDK\Exception\CleverReachException; +use CleverReach\SDK\Exception\RateLimitExceededException; +use CleverReach\SDK\Exception\ResourceNotFoundException; +use CleverReach\SDK\Exception\ValidationException; use CleverReach\SDK\Http\ApiRequestor; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; @@ -149,7 +153,7 @@ public function testRequestThrowsValidationExceptionOn400(): void { try { $this->makeRequestor()->request('POST', 'groups/123/receivers'); self::fail('Expected ValidationException was not thrown.'); - } catch (\CleverReach\SDK\Exception\ValidationException $exception) { + } catch (ValidationException $exception) { self::assertSame('Invalid email address', $exception->getMessage()); self::assertSame(400, $exception->statusCode()); } @@ -167,7 +171,7 @@ public function testRequestThrowsResourceNotFoundExceptionOn404(): void { try { $this->makeRequestor()->request('GET', 'groups/9999'); self::fail('Expected ResourceNotFoundException was not thrown.'); - } catch (\CleverReach\SDK\Exception\ResourceNotFoundException $exception) { + } catch (ResourceNotFoundException $exception) { self::assertSame('Group not found', $exception->getMessage()); self::assertSame(404, $exception->statusCode()); } @@ -185,7 +189,7 @@ public function testRequestThrowsRateLimitExceededExceptionOn429(): void { try { $this->makeRequestor()->request('GET', 'groups'); self::fail('Expected RateLimitExceededException was not thrown.'); - } catch (\CleverReach\SDK\Exception\RateLimitExceededException $exception) { + } catch (RateLimitExceededException $exception) { self::assertSame('Too many requests', $exception->getMessage()); self::assertSame(429, $exception->statusCode()); } @@ -211,11 +215,87 @@ public function testRequestWrapsHttpClientExceptions(): void { } } - public function testConstructorThrowsAuthenticationExceptionForEmptyApiToken(): void { - $this->expectException(AuthenticationException::class); - $this->expectExceptionMessage('API token must not be empty.'); + public function testConstructorDoesNotThrowForEmptyApiToken(): void { + $requestor = new ApiRequestor( + '', + 'https://rest.cleverreach.com/v3/', + $this->httpClient, + $this->requestFactory, + $this->streamFactory + ); - new ApiRequestor(" \n\t "); + $this->assertInstanceOf(ApiRequestor::class, $requestor); + } + + public function testRequestRetriesExactlyOnceOn401WithTokenProvider(): void { + $provider = $this->createMock(TokenProviderInterface::class); + $provider->expects(self::exactly(2)) + ->method('getAccessToken') + ->willReturnOnConsecutiveCalls('token_1', 'token_2') + ; + + $requestor = $this->makeRequestor(); + $requestor->setTokenProvider($provider); + + $this->requestFactory->method('createRequest')->willReturn($this->request); + + $this->request->expects(self::any()) + ->method('withHeader') + ->willReturnCallback(function (string $name, string $value) { + return $this->request; + }) + ; + + $this->httpClient->expects(self::exactly(2)) + ->method('sendRequest') + ->willReturn($this->response) + ; + + $this->response->expects(self::exactly(2)) + ->method('getStatusCode') + ->willReturnOnConsecutiveCalls(401, 200) + ; + + $this->response->method('getBody')->willReturn($this->responseBody); + $this->responseBody->method('__toString')->willReturn('{"result":"success"}'); + + $result = $requestor->request('GET', 'groups'); + self::assertSame(['result' => 'success'], $result); + } + + public function testRequestThrowsOnSecond401WithTokenProvider(): void { + $provider = $this->createMock(TokenProviderInterface::class); + $provider->expects(self::exactly(2)) + ->method('getAccessToken') + ->willReturnOnConsecutiveCalls('token_1', 'token_2') + ; + + $requestor = $this->makeRequestor(); + $requestor->setTokenProvider($provider); + + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->request->method('withHeader')->willReturnSelf(); + + $this->httpClient->expects(self::exactly(2)) + ->method('sendRequest') + ->willReturn($this->response) + ; + + $this->response->expects(self::exactly(2)) + ->method('getStatusCode') + ->willReturnOnConsecutiveCalls(401, 401) + ; + + $this->response->method('getBody')->willReturn($this->responseBody); + $this->responseBody->method('__toString')->willReturn('{"error":"Still unauthorized"}'); + + try { + $requestor->request('GET', 'groups'); + self::fail('Expected AuthenticationException'); + } catch (AuthenticationException $e) { + self::assertSame('Still unauthorized', $e->getMessage()); + self::assertSame(401, $e->statusCode()); + } } public function testRequestReturnsEmptyArrayForEmptyResponseBody(): void { From 14ec1f7dd46496a3d427b0cfb605fa475106c2eb Mon Sep 17 00:00:00 2001 From: Jannis Noordmann Date: Tue, 4 Aug 2026 11:44:43 +0200 Subject: [PATCH 2/6] docs: update readme with new functionality --- README.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fd95699..69400f6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Built on PSR-18 / PSR-17 interfaces - [Requirements](#requirements) - [Installation](#installation) - [Quick Start](#quick-start) +- [OAuth 2.0 Flow](#oauth-20-flow) - [Features](#features) - [Typed Service API](#typed-service-api) - [Groups](#groups) @@ -85,7 +86,113 @@ foreach ($groups as $group) { That's it. The SDK handles authentication, JSON encoding/decoding, and error mapping automatically. -> Get your API token in the CleverReach backend under **Account → Extras → REST API**. +> Get your API Token via the "Test process now" button in the CleverReach backend under **My Account → Interfaces → REST API** OR use the built-in OAuth functions. + +--- + +## OAuth 2.0 Flow + +If you are building an app for multiple CleverReach customers, you need to use OAuth 2.0 instead of a rigid, static API token. The SDK provides an `OAuthHelper` to manage the complete authorization flow including strict CSRF validation (state checking) and automatic token-refresh routines. + +### 1. Generating Login URL + +```php +use CleverReach\SDK\Auth\OAuthHelper; + +session_start(); + +$oauthHelper = new OAuthHelper('YOUR_CLIENT_ID', 'YOUR_CLIENT_SECRET', 'https://your-domain.com/callback'); + +// Generate secure state against CSRF +$state = bin2hex(random_bytes(16)); +$_SESSION['oauth_state'] = $state; + +$url = $oauthHelper->getAuthorizationUrl($state, ['receivers:read', 'groups:read']); + +header('Location: ' . $url); +exit; +``` + +### 2. Handling the Callback + +When CleverReach redirects the user back to your `redirect_uri` (e.g., `callback`), exchange the `code` for fully managed tokens. The SDK saves them per default on disk (`FileTokenStorage`), but you can inject a custom `TokenStorageInterface` to use Redis or Eloquent. + +> **Pro Tip:** The SDK does NOT map tokens to users automatically (to remain agnostic). In a multi-user environment, implement the `TokenStorageInterface` to link the stored `Tokens` to the currently logged-in user in your database. + +```php +use CleverReach\SDK\Auth\Exceptions\CleverReachAuthException; + +session_start(); + +try { + $expectedState = $_SESSION['oauth_state'] ?? ''; + $receivedState = $_GET['state'] ?? ''; + $code = $_GET['code'] ?? ''; + + // Exchanges the code & performs strict HMAC state validation + $tokens = $oauthHelper->exchangeCodeForToken($code, $receivedState, $expectedState); + + echo "Login success! Tokens cached."; +} catch (CleverReachAuthException $e) { + die("Authorization failed: " . $e->getMessage()); +} +``` + +### Custom Token Storage (Database/Redis) + +For multi-tenant applications, you should write your own storage adapter by implementing the `TokenStorageInterface`. This allows you to persistently store and retrieve the tokens based on your system's user ID. + +```php +use CleverReach\SDK\Auth\Storage\TokenStorageInterface; +use CleverReach\SDK\Auth\Tokens; + +class MyDatabaseTokenStorage implements TokenStorageInterface { + public function __construct(private int $userId) {} + + public function get(): ?Tokens { + // SELECT * FROM oauth_tokens WHERE user_id = $this->userId + // if found, return Tokens::fromArray($dbData); + // else return null; + } + + public function set(Tokens $tokens): void { + // UPDATE/INSERT INTO oauth_tokens WHERE user_id = $this->userId + // You can also access Scopes: $tokens->hasScope('receivers:read') + } + + public function delete(): void { + // DELETE FROM oauth_tokens WHERE user_id = $this->userId + } +} + +// Pass it to the Helper during setup +$storage = new MyDatabaseTokenStorage($_SESSION['user_id']); +$oauthHelper = new OAuthHelper('CLIENT_ID', 'SECRET', 'CALLBACK', $storage); +``` + +### 3. API Requests with OAuth + +Hook the `OAuthHelper` into your client. It acts as a `TokenProvider` and will autonomously fetch or refresh access tokens prior to any endpoint requests. + +```php +use CleverReach\SDK\CleverReachClient; + +$client = new CleverReachClient(); // Leave token empty +$client->setTokenProvider($oauthHelper); + +// SDK handles adding Bearer token. +// If the token is expired, SDK will automatically refresh & retry the API call. +$groups = $client->groups()->all(); +``` + +### Revoking Tokens (Logout) + +If a user uninstalls your app or logs out, you should actively revoke the token to invalidate it on the CleverReach servers: + +```php +// Deletes the token locally and on the server +$oauthHelper->revokeToken($tokens->getAccessToken()); +``` --- From a4721d01d10b370030478af56dbf9b6d3d76769c Mon Sep 17 00:00:00 2001 From: Jannis Noordmann Date: Tue, 4 Aug 2026 12:02:47 +0200 Subject: [PATCH 3/6] test: add tests for new and existing classes --- tests/Auth/OAuthHelperTest.php | 259 +++++++++++++++++++++++-- tests/CleverReachClientTest.php | 63 +++++- tests/Http/ApiRequestorTest.php | 42 +++- tests/Service/GroupsServiceTest.php | 240 ++++++++--------------- tests/Service/ReceiversServiceTest.php | 29 +-- 5 files changed, 429 insertions(+), 204 deletions(-) diff --git a/tests/Auth/OAuthHelperTest.php b/tests/Auth/OAuthHelperTest.php index 6994c68..d973e6c 100644 --- a/tests/Auth/OAuthHelperTest.php +++ b/tests/Auth/OAuthHelperTest.php @@ -6,11 +6,18 @@ use CleverReach\SDK\Auth\Exceptions\CleverReachAuthException; use CleverReach\SDK\Auth\OAuthHelper; +use CleverReach\SDK\Auth\Storage\TokenStorageInterface; +use CleverReach\SDK\Auth\Tokens; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; +use Psr\Http\Message\StreamInterface; /** * @internal @@ -18,27 +25,257 @@ #[CoversClass(OAuthHelper::class)] final class OAuthHelperTest extends TestCase { - public function testExchangeCodeForTokenThrowsOnEmptyStates(): void { - $httpClient = $this->createMock(ClientInterface::class); - $requestFactory = $this->createMock(RequestFactoryInterface::class); - $streamFactory = $this->createMock(StreamFactoryInterface::class); + private ClientInterface&MockObject $httpClient; + private RequestFactoryInterface&MockObject $requestFactory; + private StreamFactoryInterface&MockObject $streamFactory; + private TokenStorageInterface&MockObject $storage; + private RequestInterface&MockObject $request; + private ResponseInterface&MockObject $response; + private StreamInterface&MockObject $responseBody; + + private OAuthHelper $helper; + + protected function setUp(): void { + $this->httpClient = $this->createMock(ClientInterface::class); + $this->requestFactory = $this->createMock(RequestFactoryInterface::class); + $this->streamFactory = $this->createMock(StreamFactoryInterface::class); + $this->storage = $this->createMock(TokenStorageInterface::class); + + $this->request = $this->createMock(RequestInterface::class); + $this->response = $this->createMock(ResponseInterface::class); + $this->responseBody = $this->createMock(StreamInterface::class); - $helper = new OAuthHelper( + $this->helper = new OAuthHelper( 'client_id', 'client_secret', 'https://example.com/callback', - null, + $this->storage, 'https://rest.cleverreach.com/v3', - $httpClient, - $requestFactory, - $streamFactory + $this->httpClient, + $this->requestFactory, + $this->streamFactory ); + } + + public function testGetAuthorizationUrlThrowsOnEmptyState(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('State must not be null or empty.'); - $httpClient->expects(self::never())->method('sendRequest'); + $this->helper->getAuthorizationUrl(''); + } + + public function testGetAuthorizationUrlBuildsCorrectUrl(): void { + $url = $this->helper->getAuthorizationUrl('my_state_123', ['receivers:read']); + + self::assertSame( + 'https://rest.cleverreach.com/oauth/authorize.php?client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=code&grant=basic&state=my_state_123&scope=receivers%3Aread', + $url + ); + } + + public function testExchangeCodeForTokenThrowsOnEmptyStates(): void { + $this->httpClient->expects(self::never())->method('sendRequest'); $this->expectException(CleverReachAuthException::class); $this->expectExceptionMessage('Invalid state: state must not be empty.'); - $helper->exchangeCodeForToken('code123', '', ''); + $this->helper->exchangeCodeForToken('code123', ' ', ' '); + } + + public function testExchangeCodeForTokenThrowsOnStateMismatch(): void { + $this->httpClient->expects(self::never())->method('sendRequest'); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('Invalid state: state mismatch.'); + + $this->helper->exchangeCodeForToken('code123', 'state1', 'state2'); + } + + public function testExchangeCodeForTokenPerformsTokenRequestAndSaves(): void { + $this->setupTokenRequestMock(200, '{"access_token": "acc_123", "refresh_token": "ref_456"}'); + + $this->storage->expects(self::once())->method('set')->with(self::isInstanceOf(Tokens::class)); + + $tokens = $this->helper->exchangeCodeForToken('code123', 'state_match', 'state_match'); + + self::assertSame('acc_123', $tokens->getAccessToken()); + self::assertSame('ref_456', $tokens->getRefreshToken()); + } + + public function testRefreshAccessTokenThrowsIfNoTokenAvailable(): void { + $this->storage->method('get')->willReturn(null); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('No refresh token available.'); + + $this->helper->refreshAccessToken(); + } + + public function testRefreshAccessTokenPerformsRequestAndSaves(): void { + $this->setupTokenRequestMock(200, '{"access_token": "acc_new", "refresh_token": "ref_new"}'); + + $this->storage->expects(self::once())->method('set')->with(self::isInstanceOf(Tokens::class)); + + $tokens = $this->helper->refreshAccessToken('old_refresh_token'); + + self::assertSame('acc_new', $tokens->getAccessToken()); + } + + public function testDoTokenRequestEvictsTokensOnRefreshError4xx(): void { + $this->setupTokenRequestMock(400, '{"error": "invalid_grant"}'); + + // It should call clearStoredTokens (delete) because grant_type=refresh_token + $this->storage->expects(self::once())->method('delete'); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('OAuth request failed: invalid_grant'); + + $this->helper->refreshAccessToken('bad_refresh_token'); + } + + public function testDoTokenRequestThrowsOnInvalidJsonWithFallback(): void { + $this->setupTokenRequestMock(502, 'Bad Gateway'); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('OAuth request failed with HTTP 502 and non-JSON body.'); + + // exchangeCode triggers doTokenRequest + $this->helper->exchangeCodeForToken('code', 'st', 'st'); + } + + public function testDoTokenRequestThrowsIfAccessTokenMissing(): void { + $this->setupTokenRequestMock(200, '{"foo": "bar"}'); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('Invalid API response: missing access_token.'); + + $this->helper->exchangeCodeForToken('code', 'st', 'st'); + } + + public function testDoTokenRequestScopesAsArray(): void { + $this->setupTokenRequestMock(200, '{"access_token": "acc", "scope": ["receivers:read"]}'); + + $tokens = $this->helper->exchangeCodeForToken('code', 'st', 'st'); + self::assertSame(['receivers:read'], $tokens->getScopes()); + } + + public function testDoTokenRequestScopesAsString(): void { + $this->setupTokenRequestMock(200, '{"access_token": "acc", "scope": "receivers:read groups:manage"}'); + + $tokens = $this->helper->exchangeCodeForToken('code', 'st', 'st'); + self::assertSame(['receivers:read', 'groups:manage'], $tokens->getScopes()); + } + + public function testDoTokenRequestJsonExceptionOn200(): void { + $this->setupTokenRequestMock(200, '{"broken": '); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('Failed to decode CleverReach OAuth response.'); + + $this->helper->exchangeCodeForToken('code', 'st', 'st'); + } + + public function testDoTokenRequestThrowsFallbackMessageIfErrorStringMissing(): void { + $this->setupTokenRequestMock(403, '{"unknown_key": "forbidden"}'); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('OAuth request failed: API request failed'); + + $this->helper->exchangeCodeForToken('code', 'st', 'st'); + } + + public function testDoTokenRequestWrapsClientException(): void { + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->request->method('withHeader')->willReturnSelf(); + $this->request->method('withBody')->willReturnSelf(); + + $exception = new class('Network error') extends \RuntimeException implements ClientExceptionInterface {}; + $this->httpClient->method('sendRequest')->willThrowException($exception); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('HTTP communication during OAuth failed.'); + + $this->helper->exchangeCodeForToken('code', 'st', 'st'); + } + + public function testGetAccessTokenThrowsIfNoTokenInStorage(): void { + $this->storage->method('get')->willReturn(null); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('No access token available. Authorization required.'); + + $this->helper->getAccessToken(); + } + + public function testGetAccessTokenReturnsTokenIfNotExpired(): void { + $tokens = new Tokens('valid_acc_token', null, time() + 3600); + $this->storage->method('get')->willReturn($tokens); + + // No refresh request should be made + $this->httpClient->expects(self::never())->method('sendRequest'); + + $token = $this->helper->getAccessToken(); + self::assertSame('valid_acc_token', $token); + } + + public function testGetAccessTokenRefreshesIfExpired(): void { + // Expired token + $tokens = new Tokens('expired_acc_token', 'refresh_token', time() - 3600); + $this->storage->method('get')->willReturn($tokens); + + $this->setupTokenRequestMock(200, '{"access_token": "fresh_acc_token"}'); + + $token = $this->helper->getAccessToken(); + self::assertSame('fresh_acc_token', $token); + } + + public function testRevokeTokenSendsDeleteRequestAndClearsStorage(): void { + $this->requestFactory->expects(self::once()) + ->method('createRequest') + ->with('DELETE', 'https://rest.cleverreach.com/oauth/token') + ->willReturn($this->request); + + $this->request->expects(self::once()) + ->method('withHeader') + ->with('Authorization', 'Bearer dummy_token') + ->willReturnSelf(); + + $this->httpClient->expects(self::once()) + ->method('sendRequest') + ->with($this->request); + + $this->storage->expects(self::once())->method('delete'); + + $this->helper->revokeToken('dummy_token'); + } + + public function testRevokeTokenWrapsClientException(): void { + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->request->method('withHeader')->willReturnSelf(); + + $exception = new class('Network error') extends \RuntimeException implements ClientExceptionInterface {}; + $this->httpClient->method('sendRequest')->willThrowException($exception); + + $this->expectException(CleverReachAuthException::class); + $this->expectExceptionMessage('HTTP communication during token revocation failed.'); + + $this->helper->revokeToken('dummy_token'); + } + + private function setupTokenRequestMock(int $statusCode, string $responseBodyString): void { + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->request->method('withHeader')->willReturnSelf(); + $this->request->method('withBody')->willReturnSelf(); + + $this->httpClient->expects(self::once()) + ->method('sendRequest') + ->with($this->request) + ->willReturn($this->response); + + $this->response->method('getStatusCode')->willReturn($statusCode); + $this->response->method('getBody')->willReturn($this->responseBody); + + $this->responseBody->method('__toString')->willReturn($responseBodyString); } } + diff --git a/tests/CleverReachClientTest.php b/tests/CleverReachClientTest.php index 1530da6..ae46f8a 100644 --- a/tests/CleverReachClientTest.php +++ b/tests/CleverReachClientTest.php @@ -33,8 +33,7 @@ public function testRequestAcceptsSeparateQueryAndJsonPayload(): void { ->expects(self::once()) ->method('createRequest') ->with('POST', 'https://rest.cleverreach.com/v3/groups/42/receivers') - ->willReturn($request) - ; + ->willReturn($request); $request->method('withHeader')->willReturnSelf(); $request->method('withBody')->with($jsonStream)->willReturnSelf(); @@ -43,29 +42,73 @@ public function testRequestAcceptsSeparateQueryAndJsonPayload(): void { ->expects(self::once()) ->method('createStream') ->with('{"email":"dev@example.com"}') - ->willReturn($jsonStream) - ; + ->willReturn($jsonStream); $httpClient ->expects(self::once()) ->method('sendRequest') ->with($request) - ->willReturn($response) - ; + ->willReturn($response); $response->method('getStatusCode')->willReturn(200); $response->method('getBody')->willReturn($responseBody); $responseBody->method('__toString')->willReturn('{"ok":true}'); $client = new CleverReachClient( - apiToken: 'token', - httpClient: $httpClient, - requestFactory: $requestFactory, - streamFactory: $streamFactory + 'token', + 'https://rest.cleverreach.com/v3/', + $httpClient, + $requestFactory, + $streamFactory ); $result = $client->request('POST', 'groups/42/receivers', [], ['email' => 'dev@example.com']); self::assertSame(['ok' => true], $result); } + + public function testServiceAccessorsReturnCorrectServiceInstances(): void { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + + $client = new CleverReachClient( + 'token', + 'https://rest.cleverreach.com/v3/', + $httpClient, + $requestFactory, + $streamFactory + ); + + $groupsFirst = $client->groups(); + self::assertInstanceOf(\CleverReach\SDK\Service\GroupsService::class, $groupsFirst); + // Assert we cache the instance + self::assertSame($groupsFirst, $client->groups()); + + $receiversFirst = $client->receivers(); + self::assertInstanceOf(\CleverReach\SDK\Service\ReceiversService::class, $receiversFirst); + // Assert we cache the instance + self::assertSame($receiversFirst, $client->receivers()); + } + + public function testSetTokenProviderDelegatesToApiRequestor(): void { + $httpClient = $this->createMock(ClientInterface::class); + $requestFactory = $this->createMock(RequestFactoryInterface::class); + $streamFactory = $this->createMock(StreamFactoryInterface::class); + + $client = new CleverReachClient( + 'initial_token', + 'https://rest.cleverreach.com/v3/', + $httpClient, + $requestFactory, + $streamFactory + ); + + $provider = $this->createMock(\CleverReach\SDK\Auth\TokenProviderInterface::class); + + // the provider receives setting + $client->setTokenProvider($provider); + self::assertTrue(true); // as long as it doesn't crash, the requestor delegation worked + } } + diff --git a/tests/Http/ApiRequestorTest.php b/tests/Http/ApiRequestorTest.php index 9fd2189..6075258 100644 --- a/tests/Http/ApiRequestorTest.php +++ b/tests/Http/ApiRequestorTest.php @@ -215,16 +215,38 @@ public function testRequestWrapsHttpClientExceptions(): void { } } - public function testConstructorDoesNotThrowForEmptyApiToken(): void { - $requestor = new ApiRequestor( - '', - 'https://rest.cleverreach.com/v3/', - $this->httpClient, - $this->requestFactory, - $this->streamFactory - ); - - $this->assertInstanceOf(ApiRequestor::class, $requestor); + public function testRequestThrowsCleverReachExceptionIfNoBodyAndApiTokenEmpty(): void { + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->request->method('withHeader')->willReturnSelf(); + $this->request->method('withBody')->willReturnSelf(); + $this->httpClient->method('sendRequest')->willReturn($this->response); + + $this->response->method('getStatusCode')->willReturn(200); + $this->response->method('getBody')->willReturn($this->responseBody); + + // Return null/empty + $this->responseBody->method('__toString')->willReturn('{"ok": true}'); + + $requestor = new ApiRequestor('', 'https://rest.cleverreach.com/v3/', $this->httpClient, $this->requestFactory, $this->streamFactory); + + $result = $requestor->request('GET', 'groups', [], ['some' => 'json']); + self::assertSame(['ok' => true], $result); + } + + public function testRequestEncodesJsonException(): void { + $this->requestFactory->method('createRequest')->willReturn($this->request); + $this->request->method('withHeader')->willReturnSelf(); + + // This will simulate json_encode failing due to INF float or recursive deps + $recursive = []; + $recursive['a'] = &$recursive; + + $requestor = new ApiRequestor('token', 'https://rest.cleverreach.com/v3/', $this->httpClient, $this->requestFactory, $this->streamFactory); + + $this->expectException(CleverReachException::class); + $this->expectExceptionMessage('Failed to encode CleverReach API request JSON.'); + + $requestor->request('POST', 'groups', [], $recursive); } public function testRequestRetriesExactlyOnceOn401WithTokenProvider(): void { diff --git a/tests/Service/GroupsServiceTest.php b/tests/Service/GroupsServiceTest.php index f78bf67..51b0f72 100644 --- a/tests/Service/GroupsServiceTest.php +++ b/tests/Service/GroupsServiceTest.php @@ -6,11 +6,12 @@ use CleverReach\SDK\Collection\GroupCollection; use CleverReach\SDK\Collection\ReceiverCollection; -use CleverReach\SDK\Model\GroupModel; use CleverReach\SDK\Enum\GroupSortField; use CleverReach\SDK\Enum\ReceiverType; use CleverReach\SDK\Enum\SortOrder; +use CleverReach\SDK\Exception\ResourceNotFoundException; use CleverReach\SDK\Http\ApiRequestorInterface; +use CleverReach\SDK\Model\GroupModel; use CleverReach\SDK\Service\GroupsService; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; @@ -22,8 +23,7 @@ #[CoversClass(GroupsService::class)] final class GroupsServiceTest extends TestCase { - private const GROUP_ID = 42; - + private const GROUP_ID = 21; private ApiRequestorInterface&MockObject $requestor; private GroupsService $service; @@ -32,237 +32,153 @@ protected function setUp(): void { $this->service = new GroupsService($this->requestor); } - // ------------------------------------------------------------------------- - // get() - // ------------------------------------------------------------------------- - - public function testGetReturnsGroupForAssociativeResponse(): void { + public function testGetBuildsCorrectEndpoint(): void { $this->requestor ->expects(self::once()) ->method('request') - ->with('GET', 'groups/'.self::GROUP_ID, []) - ->willReturn(['id' => self::GROUP_ID, 'name' => 'Newsletter', 'locked' => false, 'backup' => true]) + ->with('GET', 'groups/'.self::GROUP_ID) + ->willReturn(['id' => self::GROUP_ID, 'name' => 'Newsletter List']) ; $group = $this->service->get(self::GROUP_ID); self::assertInstanceOf(GroupModel::class, $group); - self::assertSame(self::GROUP_ID, $group->id); - self::assertSame('Newsletter', $group->name); + self::assertSame('Newsletter List', $group->name); } public function testGetUnwrapsListResponseAndReturnsFirstElement(): void { $this->requestor ->method('request') - ->willReturn([['id' => 1, 'name' => 'Wrapped']]) - ; - - $group = $this->service->get(1); - - self::assertSame(1, $group->id); - self::assertSame('Wrapped', $group->name); - } - - // ------------------------------------------------------------------------- - // all() - // ------------------------------------------------------------------------- - - public function testAllReturnsGroupCollectionWithoutSorting(): void { - $this->requestor - ->expects(self::once()) - ->method('request') - ->with('GET', 'groups', ['order' => null]) ->willReturn([ - ['id' => 1, 'name' => 'Alpha'], - ['id' => 2, 'name' => 'Beta'], + ['id' => self::GROUP_ID, 'name' => 'First List'], + ['id' => 22, 'name' => 'Second List'], ]) ; - $collection = $this->service->all(); - - self::assertInstanceOf(GroupCollection::class, $collection); - self::assertCount(2, $collection); - } - - public function testAllPassesSortOrderToRequest(): void { - $this->requestor - ->expects(self::once()) - ->method('request') - ->with('GET', 'groups', ['order' => 'changed DESC']) - ->willReturn([]) - ; - - $this->service->all(GroupSortField::Changed, SortOrder::Descending); - } - - public function testAllPassesSortFieldWithoutDirectionTrimsTrailingSpace(): void { - $this->requestor - ->expects(self::once()) - ->method('request') - ->with('GET', 'groups', ['order' => 'created']) - ->willReturn([]) - ; - - $this->service->all(GroupSortField::Created); + $group = $this->service->get(self::GROUP_ID); + self::assertSame('First List', $group->name); } - public function testAllReturnsEmptyCollectionWhenResponseIsNotAList(): void { + public function testGetThrowsExceptionIfListIsEmpty(): void { $this->requestor ->method('request') - ->willReturn(['id' => 1, 'name' => 'Single object, not a list']) + ->willReturn([]) // API returns empty array for not found group sometimes ; - $collection = $this->service->all(); + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage("Group '555' not found."); - self::assertInstanceOf(GroupCollection::class, $collection); - self::assertCount(0, $collection); + $this->service->get(555); } - public function testAllPassesNoOrderWhenOnlyDirectionProvided(): void { + public function testAllBuildsCorrectEndpointWithDefaults(): void { $this->requestor ->expects(self::once()) ->method('request') ->with('GET', 'groups', ['order' => null]) - ->willReturn([]) - ; - - $this->service->all(direction: SortOrder::Ascending); - } - - // ------------------------------------------------------------------------- - // getReceivers() - // ------------------------------------------------------------------------- - - public function testGetReceiversPassesDefaultParameters(): void { - $this->requestor - ->expects(self::once()) - ->method('request') - ->with('GET', 'groups/'.self::GROUP_ID.'/receivers', [ - 'page' => 0, - 'pagesize' => 50, - 'type' => null, - 'detail' => null, - 'email_list' => null, - 'id_list' => null, - 'order_by' => null, + ->willReturn([ + ['id' => 1, 'name' => 'First List'], + ['id' => 2, 'name' => 'Second List'], ]) - ->willReturn([]) ; - $result = $this->service->getReceivers(self::GROUP_ID); + $groups = $this->service->all(); - self::assertInstanceOf(ReceiverCollection::class, $result); - self::assertCount(0, $result); + self::assertInstanceOf(GroupCollection::class, $groups); + self::assertCount(2, $groups); + self::assertSame('First List', iterator_to_array($groups)[0]->name); } - public function testGetReceiversPassesTypeFilter(): void { + public function testAllBuildsCorrectEndpointWithSorting(): void { $this->requestor ->expects(self::once()) ->method('request') - ->with('GET', 'groups/'.self::GROUP_ID.'/receivers', self::callback(static function (array $params): bool { - return $params['type'] === 'active'; - })) + ->with('GET', 'groups', ['order' => 'created DESC']) ->willReturn([]) ; - $this->service->getReceivers(self::GROUP_ID, type: ReceiverType::Active); + $this->service->all(GroupSortField::Created, SortOrder::Descending); } - public function testGetReceiversBuildsEmailListAsCommaSeparatedString(): void { + public function testAllReturnsEmptyCollectionWhenResponseIsNotAList(): void { $this->requestor - ->expects(self::once()) ->method('request') - ->with('GET', 'groups/'.self::GROUP_ID.'/receivers', self::callback(static function (array $params): bool { - return $params['email_list'] === 'a@example.com,b@example.com'; - })) - ->willReturn([]) + ->willReturn(['error' => 'something went wrong']) ; - $this->service->getReceivers(self::GROUP_ID, emailList: ['a@example.com', 'b@example.com']); + $groups = $this->service->all(); + self::assertCount(0, $groups); } - public function testGetReceiversBuildsIdListAsCommaSeparatedString(): void { + public function testGetReceiversBuildsCorrectQueryWithDefaults(): void { $this->requestor ->expects(self::once()) ->method('request') - ->with('GET', 'groups/'.self::GROUP_ID.'/receivers', self::callback(static function (array $params): bool { - return $params['id_list'] === '1,2,3'; - })) - ->willReturn([]) + ->with( + 'GET', + 'groups/'.self::GROUP_ID.'/receivers', + [ + 'page' => 0, + 'pagesize' => 50, + 'type' => null, + 'detail' => null, + 'email_list' => null, + 'id_list' => null, + 'order_by' => null, + ] + ) + ->willReturn([ + ['id' => 100, 'email' => 'jane@example.com'], + ]) ; - $this->service->getReceivers(self::GROUP_ID, idList: ['1', '2', '3']); + $receivers = $this->service->getReceivers(self::GROUP_ID); + + self::assertInstanceOf(ReceiverCollection::class, $receivers); + self::assertCount(1, $receivers); + self::assertSame('jane@example.com', iterator_to_array($receivers)[0]->email); } - public function testGetReceiversPassesOrderByWithDirection(): void { + public function testGetReceiversBuildsCorrectQueryWithAllFilters(): void { $this->requestor ->expects(self::once()) ->method('request') - ->with('GET', 'groups/'.self::GROUP_ID.'/receivers', self::callback(static function (array $params): bool { - return $params['order_by'] === 'email ASC'; - })) + ->with( + 'GET', + 'groups/'.self::GROUP_ID.'/receivers', + [ + 'page' => 2, + 'pagesize' => 10, + 'type' => 'active', + 'detail' => 7, + 'email_list' => 'a@test.com,b@test.com', + 'id_list' => '1,2,3', + 'order_by' => 'email ASC', + ] + ) ->willReturn([]) ; $this->service->getReceivers( self::GROUP_ID, - orderBy: 'email', - orderDirection: SortOrder::Ascending + 2, + 10, + ReceiverType::Active, + 7, + ['a@test.com', 'b@test.com'], + ['1', '2', '3'], + 'email', + SortOrder::Ascending ); } - public function testGetReceiversPassesOrderByWithoutDirectionTrimsTrailingSpace(): void { - $this->requestor - ->expects(self::once()) - ->method('request') - ->with('GET', 'groups/'.self::GROUP_ID.'/receivers', self::callback(static function (array $params): bool { - return $params['order_by'] === 'email'; - })) - ->willReturn([]) - ; - - $this->service->getReceivers(self::GROUP_ID, orderBy: 'email'); - } - - public function testGetReceiversPassesDetailDepth(): void { - $this->requestor - ->expects(self::once()) - ->method('request') - ->with('GET', 'groups/'.self::GROUP_ID.'/receivers', self::callback(static function (array $params): bool { - return $params['detail'] === 3; // events (1) + orders (2) - })) - ->willReturn([]) - ; - - $this->service->getReceivers(self::GROUP_ID, detail: 3); - } - public function testGetReceiversReturnsEmptyCollectionWhenResponseIsNotAList(): void { $this->requestor ->method('request') - ->willReturn(['id' => 1, 'email' => 'not-a-list@example.com']) + ->willReturn(['error' => 'no connection']) ; - $result = $this->service->getReceivers(self::GROUP_ID); - - self::assertInstanceOf(ReceiverCollection::class, $result); - self::assertCount(0, $result); - } - - public function testGetReceiversMapsResponseToReceiverCollection(): void { - $this->requestor - ->method('request') - ->willReturn([ - ['id' => 1, 'email' => 'alice@example.com'], - ['id' => 2, 'email' => 'bob@example.com'], - ]) - ; - - $result = $this->service->getReceivers(self::GROUP_ID); - - self::assertCount(2, $result); - $receivers = $result->toArray(); - self::assertSame('alice@example.com', $receivers[0]->email); - self::assertSame('bob@example.com', $receivers[1]->email); + $receivers = $this->service->getReceivers(self::GROUP_ID); + self::assertCount(0, $receivers); } } diff --git a/tests/Service/ReceiversServiceTest.php b/tests/Service/ReceiversServiceTest.php index 6d2f633..9c99108 100644 --- a/tests/Service/ReceiversServiceTest.php +++ b/tests/Service/ReceiversServiceTest.php @@ -4,6 +4,7 @@ namespace CleverReach\Tests\Service; +use CleverReach\SDK\Exception\ResourceNotFoundException; use CleverReach\SDK\Model\ReceiverModel; use CleverReach\SDK\Http\ApiRequestorInterface; use CleverReach\SDK\Service\ReceiversService; @@ -32,8 +33,7 @@ public function testGetByNumericIdBuildsCorrectEndpoint(): void { ->expects(self::once()) ->method('request') ->with('GET', 'receivers/'.self::RECEIVER_ID, ['group_id' => null]) - ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']) - ; + ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']); $receiver = $this->service->get(self::RECEIVER_ID); @@ -46,11 +46,9 @@ public function testGetByEmailBuildsCorrectEndpoint(): void { ->expects(self::once()) ->method('request') ->with('GET', 'receivers/jane@example.com', ['group_id' => null]) - ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']) - ; + ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']); $receiver = $this->service->get('jane@example.com'); - self::assertSame('jane@example.com', $receiver->email); } @@ -59,10 +57,9 @@ public function testGetPassesGroupIdAsQueryParameter(): void { ->expects(self::once()) ->method('request') ->with('GET', 'receivers/'.self::RECEIVER_ID, ['group_id' => self::GROUP_ID]) - ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']) - ; + ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']); - $this->service->get(self::RECEIVER_ID, groupId: self::GROUP_ID); + $this->service->get(self::RECEIVER_ID, self::GROUP_ID); } public function testGetUnwrapsListResponseAndReturnsFirstElement(): void { @@ -71,11 +68,21 @@ public function testGetUnwrapsListResponseAndReturnsFirstElement(): void { ->willReturn([ ['id' => self::RECEIVER_ID, 'email' => 'first@example.com'], ['id' => 2, 'email' => 'second@example.com'], - ]) - ; + ]); $receiver = $this->service->get(self::RECEIVER_ID); - self::assertSame('first@example.com', $receiver->email); } + + public function testGetThrowsExceptionWhenListIsEmpty(): void { + $this->requestor + ->method('request') + ->willReturn([]); // API returns empty list + + $this->expectException(ResourceNotFoundException::class); + $this->expectExceptionMessage("Receiver '999' not found."); + + $this->service->get(999); + } } + From 82ebf32a39d220285c91097a2de188c473edb20f Mon Sep 17 00:00:00 2001 From: Jannis Noordmann Date: Tue, 4 Aug 2026 12:05:46 +0200 Subject: [PATCH 4/6] chore: linting and test fixes --- tests/Auth/OAuthHelperTest.php | 31 ++++++++++++++------------ tests/CleverReachClientTest.php | 21 ++++++++++------- tests/Http/ApiRequestorTest.php | 12 +++++----- tests/Service/ReceiversServiceTest.php | 18 +++++++++------ 4 files changed, 47 insertions(+), 35 deletions(-) diff --git a/tests/Auth/OAuthHelperTest.php b/tests/Auth/OAuthHelperTest.php index d973e6c..95fe8d1 100644 --- a/tests/Auth/OAuthHelperTest.php +++ b/tests/Auth/OAuthHelperTest.php @@ -9,8 +9,8 @@ use CleverReach\SDK\Auth\Storage\TokenStorageInterface; use CleverReach\SDK\Auth\Tokens; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\TestCase; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientExceptionInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; @@ -26,12 +26,12 @@ final class OAuthHelperTest extends TestCase { private ClientInterface&MockObject $httpClient; - private RequestFactoryInterface&MockObject $requestFactory; - private StreamFactoryInterface&MockObject $streamFactory; - private TokenStorageInterface&MockObject $storage; - private RequestInterface&MockObject $request; - private ResponseInterface&MockObject $response; - private StreamInterface&MockObject $responseBody; + private MockObject&RequestFactoryInterface $requestFactory; + private MockObject&StreamFactoryInterface $streamFactory; + private MockObject&TokenStorageInterface $storage; + private MockObject&RequestInterface $request; + private MockObject&ResponseInterface $response; + private MockObject&StreamInterface $responseBody; private OAuthHelper $helper; @@ -188,7 +188,7 @@ public function testDoTokenRequestWrapsClientException(): void { $this->requestFactory->method('createRequest')->willReturn($this->request); $this->request->method('withHeader')->willReturnSelf(); $this->request->method('withBody')->willReturnSelf(); - + $exception = new class('Network error') extends \RuntimeException implements ClientExceptionInterface {}; $this->httpClient->method('sendRequest')->willThrowException($exception); @@ -233,16 +233,19 @@ public function testRevokeTokenSendsDeleteRequestAndClearsStorage(): void { $this->requestFactory->expects(self::once()) ->method('createRequest') ->with('DELETE', 'https://rest.cleverreach.com/oauth/token') - ->willReturn($this->request); + ->willReturn($this->request) + ; $this->request->expects(self::once()) ->method('withHeader') ->with('Authorization', 'Bearer dummy_token') - ->willReturnSelf(); + ->willReturnSelf() + ; $this->httpClient->expects(self::once()) ->method('sendRequest') - ->with($this->request); + ->with($this->request) + ; $this->storage->expects(self::once())->method('delete'); @@ -270,12 +273,12 @@ private function setupTokenRequestMock(int $statusCode, string $responseBodyStri $this->httpClient->expects(self::once()) ->method('sendRequest') ->with($this->request) - ->willReturn($this->response); + ->willReturn($this->response) + ; $this->response->method('getStatusCode')->willReturn($statusCode); $this->response->method('getBody')->willReturn($this->responseBody); - + $this->responseBody->method('__toString')->willReturn($responseBodyString); } } - diff --git a/tests/CleverReachClientTest.php b/tests/CleverReachClientTest.php index ae46f8a..8391c3d 100644 --- a/tests/CleverReachClientTest.php +++ b/tests/CleverReachClientTest.php @@ -4,7 +4,10 @@ namespace CleverReach\Tests; +use CleverReach\SDK\Auth\TokenProviderInterface; use CleverReach\SDK\CleverReachClient; +use CleverReach\SDK\Service\GroupsService; +use CleverReach\SDK\Service\ReceiversService; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Psr\Http\Client\ClientInterface; @@ -33,7 +36,8 @@ public function testRequestAcceptsSeparateQueryAndJsonPayload(): void { ->expects(self::once()) ->method('createRequest') ->with('POST', 'https://rest.cleverreach.com/v3/groups/42/receivers') - ->willReturn($request); + ->willReturn($request) + ; $request->method('withHeader')->willReturnSelf(); $request->method('withBody')->with($jsonStream)->willReturnSelf(); @@ -42,13 +46,15 @@ public function testRequestAcceptsSeparateQueryAndJsonPayload(): void { ->expects(self::once()) ->method('createStream') ->with('{"email":"dev@example.com"}') - ->willReturn($jsonStream); + ->willReturn($jsonStream) + ; $httpClient ->expects(self::once()) ->method('sendRequest') ->with($request) - ->willReturn($response); + ->willReturn($response) + ; $response->method('getStatusCode')->willReturn(200); $response->method('getBody')->willReturn($responseBody); @@ -81,12 +87,12 @@ public function testServiceAccessorsReturnCorrectServiceInstances(): void { ); $groupsFirst = $client->groups(); - self::assertInstanceOf(\CleverReach\SDK\Service\GroupsService::class, $groupsFirst); + self::assertInstanceOf(GroupsService::class, $groupsFirst); // Assert we cache the instance self::assertSame($groupsFirst, $client->groups()); $receiversFirst = $client->receivers(); - self::assertInstanceOf(\CleverReach\SDK\Service\ReceiversService::class, $receiversFirst); + self::assertInstanceOf(ReceiversService::class, $receiversFirst); // Assert we cache the instance self::assertSame($receiversFirst, $client->receivers()); } @@ -104,11 +110,10 @@ public function testSetTokenProviderDelegatesToApiRequestor(): void { $streamFactory ); - $provider = $this->createMock(\CleverReach\SDK\Auth\TokenProviderInterface::class); + $provider = $this->createMock(TokenProviderInterface::class); // the provider receives setting $client->setTokenProvider($provider); - self::assertTrue(true); // as long as it doesn't crash, the requestor delegation worked + $this->expectNotToPerformAssertions(); } } - diff --git a/tests/Http/ApiRequestorTest.php b/tests/Http/ApiRequestorTest.php index 6075258..a680747 100644 --- a/tests/Http/ApiRequestorTest.php +++ b/tests/Http/ApiRequestorTest.php @@ -223,26 +223,26 @@ public function testRequestThrowsCleverReachExceptionIfNoBodyAndApiTokenEmpty(): $this->response->method('getStatusCode')->willReturn(200); $this->response->method('getBody')->willReturn($this->responseBody); - + // Return null/empty $this->responseBody->method('__toString')->willReturn('{"ok": true}'); - + $requestor = new ApiRequestor('', 'https://rest.cleverreach.com/v3/', $this->httpClient, $this->requestFactory, $this->streamFactory); - + $result = $requestor->request('GET', 'groups', [], ['some' => 'json']); self::assertSame(['ok' => true], $result); } - + public function testRequestEncodesJsonException(): void { $this->requestFactory->method('createRequest')->willReturn($this->request); $this->request->method('withHeader')->willReturnSelf(); - + // This will simulate json_encode failing due to INF float or recursive deps $recursive = []; $recursive['a'] = &$recursive; $requestor = new ApiRequestor('token', 'https://rest.cleverreach.com/v3/', $this->httpClient, $this->requestFactory, $this->streamFactory); - + $this->expectException(CleverReachException::class); $this->expectExceptionMessage('Failed to encode CleverReach API request JSON.'); diff --git a/tests/Service/ReceiversServiceTest.php b/tests/Service/ReceiversServiceTest.php index 9c99108..a8e9cf7 100644 --- a/tests/Service/ReceiversServiceTest.php +++ b/tests/Service/ReceiversServiceTest.php @@ -5,8 +5,8 @@ namespace CleverReach\Tests\Service; use CleverReach\SDK\Exception\ResourceNotFoundException; -use CleverReach\SDK\Model\ReceiverModel; use CleverReach\SDK\Http\ApiRequestorInterface; +use CleverReach\SDK\Model\ReceiverModel; use CleverReach\SDK\Service\ReceiversService; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\MockObject\MockObject; @@ -33,7 +33,8 @@ public function testGetByNumericIdBuildsCorrectEndpoint(): void { ->expects(self::once()) ->method('request') ->with('GET', 'receivers/'.self::RECEIVER_ID, ['group_id' => null]) - ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']); + ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']) + ; $receiver = $this->service->get(self::RECEIVER_ID); @@ -46,7 +47,8 @@ public function testGetByEmailBuildsCorrectEndpoint(): void { ->expects(self::once()) ->method('request') ->with('GET', 'receivers/jane@example.com', ['group_id' => null]) - ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']); + ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']) + ; $receiver = $this->service->get('jane@example.com'); self::assertSame('jane@example.com', $receiver->email); @@ -57,7 +59,8 @@ public function testGetPassesGroupIdAsQueryParameter(): void { ->expects(self::once()) ->method('request') ->with('GET', 'receivers/'.self::RECEIVER_ID, ['group_id' => self::GROUP_ID]) - ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']); + ->willReturn(['id' => self::RECEIVER_ID, 'email' => 'jane@example.com']) + ; $this->service->get(self::RECEIVER_ID, self::GROUP_ID); } @@ -68,7 +71,8 @@ public function testGetUnwrapsListResponseAndReturnsFirstElement(): void { ->willReturn([ ['id' => self::RECEIVER_ID, 'email' => 'first@example.com'], ['id' => 2, 'email' => 'second@example.com'], - ]); + ]) + ; $receiver = $this->service->get(self::RECEIVER_ID); self::assertSame('first@example.com', $receiver->email); @@ -77,7 +81,8 @@ public function testGetUnwrapsListResponseAndReturnsFirstElement(): void { public function testGetThrowsExceptionWhenListIsEmpty(): void { $this->requestor ->method('request') - ->willReturn([]); // API returns empty list + ->willReturn([]) // API returns empty list + ; $this->expectException(ResourceNotFoundException::class); $this->expectExceptionMessage("Receiver '999' not found."); @@ -85,4 +90,3 @@ public function testGetThrowsExceptionWhenListIsEmpty(): void { $this->service->get(999); } } - From 0e665dd3d98769efeb7bdad6c7fc0478abdc79ca Mon Sep 17 00:00:00 2001 From: Jannis Noordmann Date: Tue, 4 Aug 2026 13:30:31 +0200 Subject: [PATCH 5/6] refactor: rename test method and adjusted readme --- README.md | 6 +++++- tests/Http/ApiRequestorTest.php | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 69400f6..a6e525c 100644 --- a/README.md +++ b/README.md @@ -121,15 +121,19 @@ When CleverReach redirects the user back to your `redirect_uri` (e.g., `callback ```php use CleverReach\SDK\Auth\Exceptions\CleverReachAuthException; +use CleverReach\SDK\Auth\OAuthHelper; session_start(); +// Make sure to construct the helper with exactly your credentials +$oauthHelper = new OAuthHelper('YOUR_CLIENT_ID', 'YOUR_CLIENT_SECRET', 'https://your-domain.com/callback'); + try { $expectedState = $_SESSION['oauth_state'] ?? ''; $receivedState = $_GET['state'] ?? ''; $code = $_GET['code'] ?? ''; - // Exchanges the code & performs strict HMAC state validation + // Exchanges the code and validates the returned state against the session value $tokens = $oauthHelper->exchangeCodeForToken($code, $receivedState, $expectedState); echo "Login success! Tokens cached."; diff --git a/tests/Http/ApiRequestorTest.php b/tests/Http/ApiRequestorTest.php index a680747..3d00606 100644 --- a/tests/Http/ApiRequestorTest.php +++ b/tests/Http/ApiRequestorTest.php @@ -215,7 +215,7 @@ public function testRequestWrapsHttpClientExceptions(): void { } } - public function testRequestThrowsCleverReachExceptionIfNoBodyAndApiTokenEmpty(): void { + public function testRequestDecodesResponseWhenApiTokenIsEmpty(): void { $this->requestFactory->method('createRequest')->willReturn($this->request); $this->request->method('withHeader')->willReturnSelf(); $this->request->method('withBody')->willReturnSelf(); From 055f19794f4fa4d15d64365ce5340171398956ef Mon Sep 17 00:00:00 2001 From: Jannis Noordmann Date: Tue, 4 Aug 2026 13:53:33 +0200 Subject: [PATCH 6/6] fix smaller changes --- src/Auth/OAuthHelper.php | 7 ++----- src/Http/ApiRequestor.php | 7 +++++++ tests/Auth/OAuthHelperTest.php | 2 +- tests/Http/ApiRequestorTest.php | 17 ++++------------- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/Auth/OAuthHelper.php b/src/Auth/OAuthHelper.php index d0c7cf3..6085f51 100644 --- a/src/Auth/OAuthHelper.php +++ b/src/Auth/OAuthHelper.php @@ -27,16 +27,13 @@ public function __construct( private readonly string $clientSecret, private readonly string $redirectUri, ?TokenStorageInterface $storage = null, - string $baseUri = 'https://rest.cleverreach.com/v3', + string $authBaseUrl = 'https://rest.cleverreach.com/oauth', ?ClientInterface $httpClient = null, ?RequestFactoryInterface $requestFactory = null, ?StreamFactoryInterface $streamFactory = null ) { $this->storage = $storage ?? new MemoryTokenStorage(); - - $host = parse_url($baseUri, PHP_URL_HOST) ?? 'rest.cleverreach.com'; - $scheme = parse_url($baseUri, PHP_URL_SCHEME) ?? 'https'; - $this->authBaseUrl = $scheme.'://'.$host.'/oauth'; + $this->authBaseUrl = rtrim($authBaseUrl, '/'); $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); diff --git a/src/Http/ApiRequestor.php b/src/Http/ApiRequestor.php index af3ce6a..6d75e5e 100644 --- a/src/Http/ApiRequestor.php +++ b/src/Http/ApiRequestor.php @@ -84,6 +84,13 @@ private function doRequest( ?array $json, bool $isRetrying ): array { + if ($this->tokenProvider === null && $this->apiToken === '') { + throw new AuthenticationException( + 'No authentication token provided. You must either pass a static API token in the constructor or use setTokenProvider().', + 0 + ); + } + try { $request = $this->createRequest($method, $uri, $query, $json, $isRetrying); $response = $this->httpClient->sendRequest($request); diff --git a/tests/Auth/OAuthHelperTest.php b/tests/Auth/OAuthHelperTest.php index 95fe8d1..227106d 100644 --- a/tests/Auth/OAuthHelperTest.php +++ b/tests/Auth/OAuthHelperTest.php @@ -50,7 +50,7 @@ protected function setUp(): void { 'client_secret', 'https://example.com/callback', $this->storage, - 'https://rest.cleverreach.com/v3', + 'https://rest.cleverreach.com/oauth', $this->httpClient, $this->requestFactory, $this->streamFactory diff --git a/tests/Http/ApiRequestorTest.php b/tests/Http/ApiRequestorTest.php index 3d00606..a18d324 100644 --- a/tests/Http/ApiRequestorTest.php +++ b/tests/Http/ApiRequestorTest.php @@ -215,22 +215,13 @@ public function testRequestWrapsHttpClientExceptions(): void { } } - public function testRequestDecodesResponseWhenApiTokenIsEmpty(): void { - $this->requestFactory->method('createRequest')->willReturn($this->request); - $this->request->method('withHeader')->willReturnSelf(); - $this->request->method('withBody')->willReturnSelf(); - $this->httpClient->method('sendRequest')->willReturn($this->response); - - $this->response->method('getStatusCode')->willReturn(200); - $this->response->method('getBody')->willReturn($this->responseBody); - - // Return null/empty - $this->responseBody->method('__toString')->willReturn('{"ok": true}'); + public function testRequestThrowsExceptionWhenNoTokenIsProvided(): void { + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('No authentication token provided'); $requestor = new ApiRequestor('', 'https://rest.cleverreach.com/v3/', $this->httpClient, $this->requestFactory, $this->streamFactory); - $result = $requestor->request('GET', 'groups', [], ['some' => 'json']); - self::assertSame(['ok' => true], $result); + $requestor->request('GET', 'groups', [], ['some' => 'json']); } public function testRequestEncodesJsonException(): void {