From f6eaef08fd4a27891c8cfffc5c50dc105d0ecea6 Mon Sep 17 00:00:00 2001 From: Mufthi Ryanda Date: Thu, 11 Dec 2025 17:06:17 +0700 Subject: [PATCH 1/6] feat : add firebase client --- src/Libraries/FirebaseClient.php | 295 +++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 src/Libraries/FirebaseClient.php diff --git a/src/Libraries/FirebaseClient.php b/src/Libraries/FirebaseClient.php new file mode 100644 index 0000000..4b389bd --- /dev/null +++ b/src/Libraries/FirebaseClient.php @@ -0,0 +1,295 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.3.7 + * @link https://github.com/spotlibs + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Libraries; + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Psr7\Request; +use Psr\Http\Message\ResponseInterface; +use Spotlibs\PhpLib\Logs\Log; + +/** + * FirebaseClient + * + * SDK for Firebase OAuth and FCM operations + * + * @category HttpClient + * @package Client + * @author Abdul Rasyid Anshori + * @license https://mit-license.org/ MIT License + * @link https://github.com/spotlibs + */ +class FirebaseClient +{ + private GuzzleClient $httpClient; + private array $serviceAccount; + private ?string $accessToken = null; + private ?int $tokenExpiry = null; + private string $proxyUrl = ''; + + /** + * Create Firebase client + * + * @param string $serviceAccountPath Path to service account JSON + * @param array $config Guzzle config options + */ + public function __construct(string $serviceAccountPath, array $config = []) + { + $this->serviceAccount = json_decode( + file_get_contents($serviceAccountPath), + true, + 512, + JSON_THROW_ON_ERROR + ); + + $defaultConfig = [ + 'timeout' => 60, + 'verify' => false, + ]; + + $this->httpClient = new GuzzleClient(array_merge($defaultConfig, $config)); + } + + /** + * Set proxy URL + * + * @param string $proxyUrl Proxy URL (e.g., http://proxy:port) + * + * @return self + */ + public function setProxy(string $proxyUrl): self + { + $this->proxyUrl = $proxyUrl; + return $this; + } + + /** + * Set pre-generated access token (bypass OAuth) + * + * @param string $token Access token + * @param int $expiresIn Token lifetime in seconds (default 3600) + * + * @return self + */ + public function setAccessToken(string $token, int $expiresIn = 3600): self + { + $this->accessToken = $token; + $this->tokenExpiry = time() + $expiresIn; + return $this; + } + + /** + * Generate OAuth2 access token + * + * @return string Access token + */ + public function generateToken(): string + { + if ($this->accessToken && $this->tokenExpiry > time() + 300) { + return $this->accessToken; + } + + $startTime = microtime(true); + $now = time(); + + $jwt = $this->createJWT( + [ + 'iss' => $this->serviceAccount['client_email'], + 'scope' => 'https://www.googleapis.com/auth/firebase.messaging', + 'aud' => 'https://oauth2.googleapis.com/token', + 'iat' => $now, + 'exp' => $now + 3600 + ], + $this->serviceAccount['private_key'] + ); + + $body = http_build_query( + [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt + ] + ); + + $request = new Request( + 'POST', + 'https://oauth2.googleapis.com/token', + ['Content-Type' => 'application/x-www-form-urlencoded'], + $body + ); + + $options = []; + if (!empty($this->proxyUrl)) { + $options['proxy'] = $this->proxyUrl; + } + + $response = $this->httpClient->send($request, $options); + $elapsed = microtime(true) - $startTime; + + $responseBody = json_decode( + $response->getBody()->getContents(), + true, + 512, + JSON_THROW_ON_ERROR + ); + + $this->accessToken = $responseBody['access_token']; + $this->tokenExpiry = time() + ($responseBody['expires_in'] ?? 3600); + + Log::activity()->info( + [ + 'operation' => 'firebase_oauth', + 'url' => 'https://oauth2.googleapis.com/token', + 'responseTime' => round($elapsed * 1000), + 'httpCode' => $response->getStatusCode() + ] + ); + + return $this->accessToken; + } + + /** + * Send FCM message + * + * @param array $message FCM message payload + * + * @return ResponseInterface + */ + public function sendMessage(array $message): ResponseInterface + { + $token = $this->generateToken(); + $startTime = microtime(true); + + $projectId = $this->serviceAccount['project_id']; + $url = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"; + + $request = new Request( + 'POST', + $url, + [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json' + ], + json_encode(['message' => $message], JSON_THROW_ON_ERROR) + ); + + $options = []; + if (!empty($this->proxyUrl)) { + $options['proxy'] = $this->proxyUrl; + } + + $response = $this->httpClient->send($request, $options); + $elapsed = microtime(true) - $startTime; + + $respBody = $response->getBody()->getContents(); + $response->getBody()->rewind(); + + Log::activity()->info( + [ + 'operation' => 'firebase_fcm_send', + 'host' => 'fcm.googleapis.com', + 'url' => "/v1/projects/{$projectId}/messages:send", + 'request' => ['body' => $message], + 'response' => [ + 'httpCode' => $response->getStatusCode(), + 'body' => json_decode($respBody, true) + ], + 'responseTime' => round($elapsed * 1000) + ] + ); + + return $response; + } + + /** + * Send to multiple tokens (multicast) + * + * @param array $tokens FCM registration tokens + * @param array $notification Notification payload + * @param array $data Data payload + * + * @return array Results with success/failure counts + */ + public function sendMulticast( + array $tokens, + array $notification = [], + array $data = [] + ): array { + $results = ['success' => 0, 'failure' => 0, 'responses' => []]; + + foreach ($tokens as $token) { + $message = ['token' => $token]; + if (!empty($notification)) { + $message['notification'] = $notification; + } + if (!empty($data)) { + $message['data'] = $data; + } + + try { + $response = $this->sendMessage($message); + if ($response->getStatusCode() === 200) { + $results['success']++; + $results['responses'][] = [ + 'token' => $token, + 'success' => true + ]; + } else { + $results['failure']++; + $results['responses'][] = [ + 'token' => $token, + 'success' => false, + 'error' => $response->getBody()->getContents() + ]; + } + } catch (\Throwable $e) { + $results['failure']++; + $results['responses'][] = [ + 'token' => $token, + 'success' => false, + 'error' => $e->getMessage() + ]; + } + } + + return $results; + } + + /** + * Generate JWT manually using OpenSSL + * + * @param array $payload JWT payload + * @param string $privateKey RSA private key + * + * @return string JWT token + */ + private function createJWT(array $payload, string $privateKey): string + { + $header = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); + $payload = base64_encode(json_encode($payload)); + + $signature = ''; + openssl_sign( + $header . '.' . $payload, + $signature, + $privateKey, + OPENSSL_ALGO_SHA256 + ); + + $signature = base64_encode($signature); + + // Make base64url safe + return str_replace(['+', '/', '='], ['-', '_', ''], $header . '.' . $payload . '.' . $signature); + } +} From 9677960034aa7d5ee011de8de1ee6336051263eb Mon Sep 17 00:00:00 2001 From: Mufthi Ryanda Date: Thu, 11 Dec 2025 17:54:40 +0700 Subject: [PATCH 2/6] feat : add unit test --- tests/Libraries/FirebaseClientTest.php | 239 +++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 tests/Libraries/FirebaseClientTest.php diff --git a/tests/Libraries/FirebaseClientTest.php b/tests/Libraries/FirebaseClientTest.php new file mode 100644 index 0000000..3ca2a39 --- /dev/null +++ b/tests/Libraries/FirebaseClientTest.php @@ -0,0 +1,239 @@ +testServiceAccountPath = sys_get_temp_dir() . '/test-service-account.json'; + file_put_contents($this->testServiceAccountPath, json_encode([ + 'type' => 'service_account', + 'project_id' => 'test-project', + 'private_key_id' => 'test-key-id', + 'private_key' => 'mock-private-key', + 'client_email' => 'test@test-project.iam.gserviceaccount.com', + 'client_id' => '123456789', + 'auth_uri' => 'https://accounts.google.com/o/oauth2/auth', + 'token_uri' => 'https://oauth2.googleapis.com/token', + ])); + } + + protected function tearDown(): void + { + if (file_exists($this->testServiceAccountPath)) { + unlink($this->testServiceAccountPath); + } + parent::tearDown(); + } + + public function testSetAccessToken(): void + { + $client = new FirebaseClient($this->testServiceAccountPath); + $client->setAccessToken('test-token', 3600); + + $this->assertEquals('test-token', $client->generateToken()); + } + + public function testSetProxy(): void + { + $client = new FirebaseClient($this->testServiceAccountPath); + $result = $client->setProxy('http://proxy:1707'); + + $this->assertInstanceOf(FirebaseClient::class, $result); + } + + public function testGenerateToken(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'access_token' => 'ya29.test-token', + 'expires_in' => 3600, + 'token_type' => 'Bearer' + ])), + ]); + $handlerStack = HandlerStack::create($mock); + + $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); + // Pre-set token to bypass JWT creation + $client->setAccessToken('ya29.test-token', 3600); + $token = $client->generateToken(); + + $this->assertEquals('ya29.test-token', $token); + } + + public function testGenerateTokenWithProxy(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'access_token' => 'ya29.proxy-token', + 'expires_in' => 3600 + ])), + ]); + $handlerStack = HandlerStack::create($mock); + + $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); + $client->setProxy('http://proxy:1707'); + // Pre-set token to bypass JWT creation + $client->setAccessToken('ya29.proxy-token', 3600); + $token = $client->generateToken(); + + $this->assertEquals('ya29.proxy-token', $token); + } + + public function testSendMessage(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'name' => 'projects/test-project/messages/0:123456' + ])), + ]); + $handlerStack = HandlerStack::create($mock); + + $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); + // Pre-set token to bypass JWT creation + $client->setAccessToken('ya29.test', 3600); + + $response = $client->sendMessage([ + 'token' => 'device-token', + 'notification' => ['title' => 'Test', 'body' => 'Hello'] + ]); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertStringContainsString('projects/test-project/messages', $contents['name']); + } + + public function testSendMessageWithPreGeneratedToken(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'name' => 'projects/test-project/messages/0:789012' + ])), + ]); + $handlerStack = HandlerStack::create($mock); + + $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); + $client->setAccessToken('pre-generated-token'); + + $response = $client->sendMessage([ + 'token' => 'device-token', + 'notification' => ['title' => 'Test', 'body' => 'Hello'] + ]); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertStringContainsString('messages', $contents['name']); + } + + public function testSendMulticast(): void + { + $mock = new MockHandler([ + new Response(200, [], json_encode(['name' => 'msg1'])), + new Response(200, [], json_encode(['name' => 'msg2'])), + new Response(404, [], json_encode(['error' => 'not found'])), + ]); + $handlerStack = HandlerStack::create($mock); + + $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); + // Pre-set token to bypass JWT creation + $client->setAccessToken('ya29.test', 3600); + + $result = $client->sendMulticast( + ['token1', 'token2', 'token3'], + ['title' => 'Test', 'body' => 'Hello'], + ['key' => 'value'] + ); + + $this->assertEquals(2, $result['success']); + $this->assertEquals(1, $result['failure']); + $this->assertCount(3, $result['responses']); + } + + public function testSendMulticastAllSuccess(): void + { + $mock = new MockHandler([ + new Response(200, [], json_encode(['name' => 'msg1'])), + new Response(200, [], json_encode(['name' => 'msg2'])), + ]); + $handlerStack = HandlerStack::create($mock); + + $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); + // Pre-set token to bypass JWT creation + $client->setAccessToken('ya29.test', 3600); + + $result = $client->sendMulticast( + ['token1', 'token2'], + ['title' => 'Test'], + [] + ); + + $this->assertEquals(2, $result['success']); + $this->assertEquals(0, $result['failure']); + } + + public function testOAuthConnectionError(): void + { + $this->expectException(\GuzzleHttp\Exception\ConnectException::class); + + $request = new Request('POST', 'https://fcm.googleapis.com'); + $mock = new MockHandler([ + new ConnectException('Connection failed', $request) + ]); + $handlerStack = HandlerStack::create($mock); + + $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); + // Pre-set token to bypass JWT creation + $client->setAccessToken('token', 3600); + + // Force a connection error by trying to send a message + $client->sendMessage(['token' => 'test']); + } + + public function testFCMConnectionError(): void + { + $request = new Request('POST', 'https://fcm.googleapis.com'); + $mock = new MockHandler([ + new ConnectException('FCM unavailable', $request) + ]); + $handlerStack = HandlerStack::create($mock); + + $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); + // Pre-set token to bypass JWT creation + $client->setAccessToken('token', 3600); + + $this->expectException(\GuzzleHttp\Exception\ConnectException::class); + $client->sendMessage(['token' => 'test']); + } + + public function testTokenCaching(): void + { + $client = new FirebaseClient($this->testServiceAccountPath); + // Pre-set token to test caching + $client->setAccessToken('cached-token', 3600); + + $token1 = $client->generateToken(); + $token2 = $client->generateToken(); // Should return cached token + + $this->assertEquals($token1, $token2); + $this->assertEquals('cached-token', $token2); + } +} \ No newline at end of file From 3187402861b61009b98c5701087baae105bf8b56 Mon Sep 17 00:00:00 2001 From: Mufthi Ryanda Date: Fri, 12 Dec 2025 09:39:03 +0700 Subject: [PATCH 3/6] chore : change log type --- src/Libraries/FirebaseClient.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Libraries/FirebaseClient.php b/src/Libraries/FirebaseClient.php index 4b389bd..c32753d 100644 --- a/src/Libraries/FirebaseClient.php +++ b/src/Libraries/FirebaseClient.php @@ -147,7 +147,7 @@ public function generateToken(): string $this->accessToken = $responseBody['access_token']; $this->tokenExpiry = time() + ($responseBody['expires_in'] ?? 3600); - Log::activity()->info( + Log::runtime()->info( [ 'operation' => 'firebase_oauth', 'url' => 'https://oauth2.googleapis.com/token', @@ -195,7 +195,7 @@ public function sendMessage(array $message): ResponseInterface $respBody = $response->getBody()->getContents(); $response->getBody()->rewind(); - Log::activity()->info( + Log::runtime()->info( [ 'operation' => 'firebase_fcm_send', 'host' => 'fcm.googleapis.com', From a2e2bca9d238c10f4a4cd09697644b5f86c17acd Mon Sep 17 00:00:00 2001 From: Mufthi Ryanda Date: Mon, 15 Dec 2025 14:51:51 +0700 Subject: [PATCH 4/6] feat : add singleton config token --- src/Libraries/FirebaseClient.php | 152 ++++++---- tests/Libraries/FirebaseClientTest.php | 370 ++++++++++++------------- 2 files changed, 281 insertions(+), 241 deletions(-) diff --git a/src/Libraries/FirebaseClient.php b/src/Libraries/FirebaseClient.php index c32753d..c116e7b 100644 --- a/src/Libraries/FirebaseClient.php +++ b/src/Libraries/FirebaseClient.php @@ -16,18 +16,20 @@ namespace Spotlibs\PhpLib\Libraries; use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Psr7\Request; use Psr\Http\Message\ResponseInterface; +use Spotlibs\PhpLib\Exceptions\RuntimeException; use Spotlibs\PhpLib\Logs\Log; /** * FirebaseClient * - * SDK for Firebase OAuth and FCM operations + * SDK for Firebase OAuth and FCM operations with singleton token support * * @category HttpClient * @package Client - * @author Abdul Rasyid Anshori + * @author Mufthi Ryanda * @license https://mit-license.org/ MIT License * @link https://github.com/spotlibs */ @@ -35,20 +37,28 @@ class FirebaseClient { private GuzzleClient $httpClient; private array $serviceAccount; - private ?string $accessToken = null; - private ?int $tokenExpiry = null; private string $proxyUrl = ''; /** * Create Firebase client * - * @param string $serviceAccountPath Path to service account JSON - * @param array $config Guzzle config options + * @param array $config Guzzle config options + * + * @throws RuntimeException When FIREBASE_CREDENTIALS env not set */ - public function __construct(string $serviceAccountPath, array $config = []) + public function __construct(array $config = []) { + $serviceAccountPath = env('FIREBASE_CREDENTIALS'); + if (empty($serviceAccountPath)) { + throw new RuntimeException('FIREBASE_CREDENTIALS environment variable is not set'); + } + $fullPath = base_path($serviceAccountPath); + if (!file_exists($fullPath)) { + throw new RuntimeException("Firebase credentials file not found: {$fullPath}"); + } + $this->serviceAccount = json_decode( - file_get_contents($serviceAccountPath), + file_get_contents($fullPath), true, 512, JSON_THROW_ON_ERROR @@ -75,32 +85,15 @@ public function setProxy(string $proxyUrl): self return $this; } - /** - * Set pre-generated access token (bypass OAuth) - * - * @param string $token Access token - * @param int $expiresIn Token lifetime in seconds (default 3600) - * - * @return self - */ - public function setAccessToken(string $token, int $expiresIn = 3600): self - { - $this->accessToken = $token; - $this->tokenExpiry = time() + $expiresIn; - return $this; - } - /** * Generate OAuth2 access token * - * @return string Access token + * @return array Array with 'token' and 'expiry' keys + * + * @throws \GuzzleHttp\Exception\GuzzleException On HTTP error */ - public function generateToken(): string + public function generateToken(): array { - if ($this->accessToken && $this->tokenExpiry > time() + 300) { - return $this->accessToken; - } - $startTime = microtime(true); $now = time(); @@ -144,8 +137,9 @@ public function generateToken(): string JSON_THROW_ON_ERROR ); - $this->accessToken = $responseBody['access_token']; - $this->tokenExpiry = time() + ($responseBody['expires_in'] ?? 3600); + $token = $responseBody['access_token']; + $expiresIn = $responseBody['expires_in'] ?? 3600; + $expiry = time() + $expiresIn; Log::runtime()->info( [ @@ -156,7 +150,7 @@ public function generateToken(): string ] ); - return $this->accessToken; + return ['token' => $token, 'expiry' => $expiry]; } /** @@ -165,12 +159,29 @@ public function generateToken(): string * @param array $message FCM message payload * * @return ResponseInterface + * + * @throws \GuzzleHttp\Exception\GuzzleException On HTTP error */ public function sendMessage(array $message): ResponseInterface { - $token = $this->generateToken(); - $startTime = microtime(true); + // Get token from singleton + $tokenData = app('firebase.token'); + + // Check if empty or expired (with 5 min buffer) + if (empty($tokenData['token']) || $tokenData['expiry'] <= time() + 300) { + Log::runtime()->info( + [ + 'operation' => 'firebase_token_refresh', + 'reason' => empty($tokenData['token']) ? 'empty' : 'expired' + ] + ); + + // Regenerate and update singleton + app()->forgetInstance('firebase.token'); + $tokenData = app('firebase.token'); + } + $startTime = microtime(true); $projectId = $this->serviceAccount['project_id']; $url = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"; @@ -178,7 +189,7 @@ public function sendMessage(array $message): ResponseInterface 'POST', $url, [ - 'Authorization' => 'Bearer ' . $token, + 'Authorization' => 'Bearer ' . $tokenData['token'], 'Content-Type' => 'application/json' ], json_encode(['message' => $message], JSON_THROW_ON_ERROR) @@ -189,27 +200,57 @@ public function sendMessage(array $message): ResponseInterface $options['proxy'] = $this->proxyUrl; } - $response = $this->httpClient->send($request, $options); - $elapsed = microtime(true) - $startTime; - - $respBody = $response->getBody()->getContents(); - $response->getBody()->rewind(); - - Log::runtime()->info( - [ - 'operation' => 'firebase_fcm_send', - 'host' => 'fcm.googleapis.com', - 'url' => "/v1/projects/{$projectId}/messages:send", - 'request' => ['body' => $message], - 'response' => [ - 'httpCode' => $response->getStatusCode(), - 'body' => json_decode($respBody, true) - ], - 'responseTime' => round($elapsed * 1000) - ] - ); + try { + $response = $this->httpClient->send($request, $options); + $elapsed = microtime(true) - $startTime; + + $respBody = $response->getBody()->getContents(); + $response->getBody()->rewind(); + + Log::runtime()->info( + [ + 'operation' => 'firebase_fcm_send', + 'host' => 'fcm.googleapis.com', + 'url' => "/v1/projects/{$projectId}/messages:send", + 'request' => ['body' => $message], + 'response' => [ + 'httpCode' => $response->getStatusCode(), + 'body' => json_decode($respBody, true) + ], + 'responseTime' => round($elapsed * 1000) + ] + ); + + return $response; + } catch (ClientException $e) { + // On 401, regenerate token and retry once + if ($e->getResponse()->getStatusCode() === 401) { + Log::runtime()->warning( + [ + 'operation' => 'firebase_fcm_send_401', + 'message' => 'Token unauthorized, regenerating and retrying' + ] + ); + + // Regenerate and update singleton + app()->forgetInstance('firebase.token'); + $newTokenData = app('firebase.token'); + + $retryRequest = new Request( + 'POST', + $url, + [ + 'Authorization' => 'Bearer ' . $newTokenData['token'], + 'Content-Type' => 'application/json' + ], + json_encode(['message' => $message], JSON_THROW_ON_ERROR) + ); + + return $this->httpClient->send($retryRequest, $options); + } - return $response; + throw $e; + } } /** @@ -289,7 +330,6 @@ private function createJWT(array $payload, string $privateKey): string $signature = base64_encode($signature); - // Make base64url safe return str_replace(['+', '/', '='], ['-', '_', ''], $header . '.' . $payload . '.' . $signature); } } diff --git a/tests/Libraries/FirebaseClientTest.php b/tests/Libraries/FirebaseClientTest.php index 3ca2a39..48fbc45 100644 --- a/tests/Libraries/FirebaseClientTest.php +++ b/tests/Libraries/FirebaseClientTest.php @@ -1,239 +1,239 @@ + * @license https://mit-license.org/ MIT License + * @version GIT: 0.3.7 + * @link https://github.com/spotlibs + */ + declare(strict_types=1); namespace Tests\Libraries; -use GuzzleHttp\Handler\MockHandler; -use GuzzleHttp\HandlerStack; +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Psr7\Response; -use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Psr7\Request; -use Laravel\Lumen\Testing\TestCase; +use Mockery; use Spotlibs\PhpLib\Libraries\FirebaseClient; +use Tests\TestCase; class FirebaseClientTest extends TestCase { - private string $testServiceAccountPath; - - public function createApplication() - { - return require __DIR__.'/../../bootstrap/app.php'; - } + // Real valid RSA private key for testing (2048-bit) + private string $validPrivateKey = "-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAyPFw8D7OUFNJ8u7v7F3aZ0Xy7b9F1dF8F9F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0IDAQAB +AoIBABx9F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGBAP +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGBAN +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGAF +0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGBAP +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGAF +0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 +F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0= +-----END RSA PRIVATE KEY-----"; + + private array $mockServiceAccount = [ + 'type' => 'service_account', + 'project_id' => 'test-project', + 'private_key_id' => 'key123', + 'private_key' => '', + 'client_email' => 'test@test-project.iam.gserviceaccount.com', + 'client_id' => '12345', + ]; protected function setUp(): void { parent::setUp(); - - // Create mock service account file - key doesn't need to be valid since we'll mock everything - $this->testServiceAccountPath = sys_get_temp_dir() . '/test-service-account.json'; - file_put_contents($this->testServiceAccountPath, json_encode([ - 'type' => 'service_account', - 'project_id' => 'test-project', - 'private_key_id' => 'test-key-id', - 'private_key' => 'mock-private-key', - 'client_email' => 'test@test-project.iam.gserviceaccount.com', - 'client_id' => '123456789', - 'auth_uri' => 'https://accounts.google.com/o/oauth2/auth', - 'token_uri' => 'https://oauth2.googleapis.com/token', - ])); + $this->mockServiceAccount['private_key'] = $this->validPrivateKey; } - protected function tearDown(): void + /** @test */ + /** @runInSeparateProcess */ + public function testSendMessage(): void { - if (file_exists($this->testServiceAccountPath)) { - unlink($this->testServiceAccountPath); - } - parent::tearDown(); - } + $this->setupAppToken(); - public function testSetAccessToken(): void - { - $client = new FirebaseClient($this->testServiceAccountPath); - $client->setAccessToken('test-token', 3600); + $mockResponse = new Response( + 200, + [], + json_encode(['name' => 'projects/test-project/messages/123']) + ); - $this->assertEquals('test-token', $client->generateToken()); - } + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); - public function testSetProxy(): void - { - $client = new FirebaseClient($this->testServiceAccountPath); - $result = $client->setProxy('http://proxy:1707'); + $client = $this->createMockedClient($guzzleMock); - $this->assertInstanceOf(FirebaseClient::class, $result); - } + $message = [ + 'token' => 'device_token_123', + 'notification' => [ + 'title' => 'Test', + 'body' => 'Test message' + ] + ]; - public function testGenerateToken(): void - { - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], json_encode([ - 'access_token' => 'ya29.test-token', - 'expires_in' => 3600, - 'token_type' => 'Bearer' - ])), - ]); - $handlerStack = HandlerStack::create($mock); - - $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); - // Pre-set token to bypass JWT creation - $client->setAccessToken('ya29.test-token', 3600); - $token = $client->generateToken(); - - $this->assertEquals('ya29.test-token', $token); - } + $response = $client->sendMessage($message); - public function testGenerateTokenWithProxy(): void - { - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], json_encode([ - 'access_token' => 'ya29.proxy-token', - 'expires_in' => 3600 - ])), - ]); - $handlerStack = HandlerStack::create($mock); - - $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); - $client->setProxy('http://proxy:1707'); - // Pre-set token to bypass JWT creation - $client->setAccessToken('ya29.proxy-token', 3600); - $token = $client->generateToken(); - - $this->assertEquals('ya29.proxy-token', $token); + $this->assertEquals(200, $response->getStatusCode()); } - public function testSendMessage(): void + /** @test */ + /** @runInSeparateProcess */ + public function testSendMessageRetryOn401(): void { - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], json_encode([ - 'name' => 'projects/test-project/messages/0:123456' - ])), - ]); - $handlerStack = HandlerStack::create($mock); - - $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); - // Pre-set token to bypass JWT creation - $client->setAccessToken('ya29.test', 3600); - - $response = $client->sendMessage([ - 'token' => 'device-token', - 'notification' => ['title' => 'Test', 'body' => 'Hello'] - ]); - - $contents = json_decode($response->getBody()->getContents(), true); - $this->assertStringContainsString('projects/test-project/messages', $contents['name']); - } + $this->setupAppToken(); - public function testSendMessageWithPreGeneratedToken(): void - { - $mock = new MockHandler([ - new Response(200, ['Content-Type' => 'application/json'], json_encode([ - 'name' => 'projects/test-project/messages/0:789012' - ])), - ]); - $handlerStack = HandlerStack::create($mock); - - $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); - $client->setAccessToken('pre-generated-token'); - - $response = $client->sendMessage([ - 'token' => 'device-token', - 'notification' => ['title' => 'Test', 'body' => 'Hello'] - ]); - - $contents = json_decode($response->getBody()->getContents(), true); - $this->assertStringContainsString('messages', $contents['name']); + $unauthorizedResponse = new Response(401, [], json_encode(['error' => 'Unauthorized'])); + $successResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + + $exception = new ClientException( + 'Unauthorized', + new Request('POST', 'https://fcm.googleapis.com'), + $unauthorizedResponse + ); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andThrow($exception); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($successResponse); + + $client = $this->createMockedClient($guzzleMock); + + $message = ['token' => 'device_token_123']; + $response = $client->sendMessage($message); + + $this->assertEquals(200, $response->getStatusCode()); } + /** @test */ + /** @runInSeparateProcess */ public function testSendMulticast(): void { - $mock = new MockHandler([ - new Response(200, [], json_encode(['name' => 'msg1'])), - new Response(200, [], json_encode(['name' => 'msg2'])), - new Response(404, [], json_encode(['error' => 'not found'])), - ]); - $handlerStack = HandlerStack::create($mock); - - $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); - // Pre-set token to bypass JWT creation - $client->setAccessToken('ya29.test', 3600); - - $result = $client->sendMulticast( - ['token1', 'token2', 'token3'], - ['title' => 'Test', 'body' => 'Hello'], - ['key' => 'value'] - ); + $this->setupAppToken(); - $this->assertEquals(2, $result['success']); - $this->assertEquals(1, $result['failure']); + $mockResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->times(3) + ->andReturn($mockResponse); + + $client = $this->createMockedClient($guzzleMock); + + $tokens = ['token1', 'token2', 'token3']; + $notification = ['title' => 'Test', 'body' => 'Message']; + + $result = $client->sendMulticast($tokens, $notification); + + $this->assertEquals(3, $result['success']); + $this->assertEquals(0, $result['failure']); $this->assertCount(3, $result['responses']); } - public function testSendMulticastAllSuccess(): void + /** @test */ + /** @runInSeparateProcess */ + public function testSendMulticastWithFailures(): void { - $mock = new MockHandler([ - new Response(200, [], json_encode(['name' => 'msg1'])), - new Response(200, [], json_encode(['name' => 'msg2'])), - ]); - $handlerStack = HandlerStack::create($mock); - - $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); - // Pre-set token to bypass JWT creation - $client->setAccessToken('ya29.test', 3600); - - $result = $client->sendMulticast( - ['token1', 'token2'], - ['title' => 'Test'], - [] - ); + $this->setupAppToken(); - $this->assertEquals(2, $result['success']); - $this->assertEquals(0, $result['failure']); + $successResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + $errorResponse = new Response(400, [], json_encode(['error' => 'Invalid token'])); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($successResponse); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($errorResponse); + + $client = $this->createMockedClient($guzzleMock); + + $tokens = ['token1', 'token2']; + $result = $client->sendMulticast($tokens); + + $this->assertEquals(1, $result['success']); + $this->assertEquals(1, $result['failure']); } - public function testOAuthConnectionError(): void + /** @test */ + /** @runInSeparateProcess */ + public function testSetProxy(): void { - $this->expectException(\GuzzleHttp\Exception\ConnectException::class); - - $request = new Request('POST', 'https://fcm.googleapis.com'); - $mock = new MockHandler([ - new ConnectException('Connection failed', $request) - ]); - $handlerStack = HandlerStack::create($mock); + $guzzleMock = Mockery::mock(GuzzleClient::class); + $client = $this->createMockedClient($guzzleMock); - $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); - // Pre-set token to bypass JWT creation - $client->setAccessToken('token', 3600); + $result = $client->setProxy('http://proxy.example.com:8080'); - // Force a connection error by trying to send a message - $client->sendMessage(['token' => 'test']); + $this->assertInstanceOf(FirebaseClient::class, $result); } - public function testFCMConnectionError(): void + private function setupAppToken(): void { - $request = new Request('POST', 'https://fcm.googleapis.com'); - $mock = new MockHandler([ - new ConnectException('FCM unavailable', $request) - ]); - $handlerStack = HandlerStack::create($mock); - - $client = new FirebaseClient($this->testServiceAccountPath, ['handler' => $handlerStack]); - // Pre-set token to bypass JWT creation - $client->setAccessToken('token', 3600); - - $this->expectException(\GuzzleHttp\Exception\ConnectException::class); - $client->sendMessage(['token' => 'test']); + $tokenData = [ + 'token' => 'mock_token_12345', + 'expiry' => time() + 3600 + ]; + + $this->app->singleton('firebase.token', function() use ($tokenData) { + return $tokenData; + }); } - public function testTokenCaching(): void + private function createMockedClient($guzzleMock): FirebaseClient { - $client = new FirebaseClient($this->testServiceAccountPath); - // Pre-set token to test caching - $client->setAccessToken('cached-token', 3600); + $reflection = new \ReflectionClass(FirebaseClient::class); + $instance = $reflection->newInstanceWithoutConstructor(); + + $httpClientProperty = $reflection->getProperty('httpClient'); + $httpClientProperty->setAccessible(true); + $httpClientProperty->setValue($instance, $guzzleMock); + + $serviceAccountProperty = $reflection->getProperty('serviceAccount'); + $serviceAccountProperty->setAccessible(true); + $serviceAccountProperty->setValue($instance, $this->mockServiceAccount); - $token1 = $client->generateToken(); - $token2 = $client->generateToken(); // Should return cached token + $proxyProperty = $reflection->getProperty('proxyUrl'); + $proxyProperty->setAccessible(true); + $proxyProperty->setValue($instance, ''); - $this->assertEquals($token1, $token2); - $this->assertEquals('cached-token', $token2); + return $instance; } } \ No newline at end of file From 9a2c59e74413e125cab6b96c478b16a1eb260613 Mon Sep 17 00:00:00 2001 From: Mufthi Ryanda Date: Wed, 17 Dec 2025 10:56:14 +0700 Subject: [PATCH 5/6] feat : add FirebaseClient --- src/Libraries/FirebaseClient.php | 142 +++++++++++----- tests/Libraries/FirebaseClientTest.php | 222 +++++++++++++++---------- 2 files changed, 237 insertions(+), 127 deletions(-) diff --git a/src/Libraries/FirebaseClient.php b/src/Libraries/FirebaseClient.php index c116e7b..cece483 100644 --- a/src/Libraries/FirebaseClient.php +++ b/src/Libraries/FirebaseClient.php @@ -7,7 +7,7 @@ * @package Libraries * @author Mufthi Ryanda * @license https://mit-license.org/ MIT License - * @version GIT: 0.3.7 + * @version GIT: 0.3.8 * @link https://github.com/spotlibs */ @@ -25,7 +25,7 @@ /** * FirebaseClient * - * SDK for Firebase OAuth and FCM operations with singleton token support + * SDK for Firebase OAuth and FCM operations with file-based token persistence * * @category HttpClient * @package Client @@ -38,6 +38,7 @@ class FirebaseClient private GuzzleClient $httpClient; private array $serviceAccount; private string $proxyUrl = ''; + private string $tokenFile; /** * Create Firebase client @@ -64,6 +65,15 @@ public function __construct(array $config = []) JSON_THROW_ON_ERROR ); + // Set token file path in storage + $this->tokenFile = storage_path('framework/cache/firebase_token.json'); + + // Ensure directory exists + $dir = dirname($this->tokenFile); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + $defaultConfig = [ 'timeout' => 60, 'verify' => false, @@ -85,6 +95,71 @@ public function setProxy(string $proxyUrl): self return $this; } + /** + * Get or refresh access token from file + * + * @param bool $forceRefresh Force token regeneration + * + * @return string Access token + * + * @throws \GuzzleHttp\Exception\GuzzleException On HTTP error + */ + private function getAccessToken(bool $forceRefresh = false): string + { + // Try to read existing token + if (!$forceRefresh && file_exists($this->tokenFile)) { + $handle = fopen($this->tokenFile, 'r'); + if ($handle && flock($handle, LOCK_SH)) { + $content = fread($handle, filesize($this->tokenFile)); + flock($handle, LOCK_UN); + fclose($handle); + + $tokenData = json_decode($content, true); + + // Check if token is still valid (with 5 min buffer) + if ($tokenData && isset($tokenData['token'], $tokenData['expiry']) && $tokenData['expiry'] > time() + 300) { + return $tokenData['token']; + } + } elseif ($handle) { + fclose($handle); + } + } + + Log::runtime()->info( + [ + 'operation' => 'firebase_token_refresh', + 'reason' => $forceRefresh ? 'forced' : (!file_exists($this->tokenFile) ? 'empty' : 'expired') + ] + ); + + // Generate new token and save to file + $tokenData = $this->generateToken(); + $this->saveTokenToFile($tokenData); + + return $tokenData['token']; + } + + /** + * Save token data to file with lock + * + * @param array $tokenData Token data with 'token' and 'expiry' keys + * + * @return void + */ + private function saveTokenToFile(array $tokenData): void + { + $handle = fopen($this->tokenFile, 'c'); + if ($handle && flock($handle, LOCK_EX)) { + ftruncate($handle, 0); + fwrite($handle, json_encode($tokenData, JSON_THROW_ON_ERROR)); + fflush($handle); + flock($handle, LOCK_UN); + } + if ($handle) { + fclose($handle); + } + } + /** * Generate OAuth2 access token * @@ -92,7 +167,7 @@ public function setProxy(string $proxyUrl): self * * @throws \GuzzleHttp\Exception\GuzzleException On HTTP error */ - public function generateToken(): array + private function generateToken(): array { $startTime = microtime(true); $now = time(); @@ -110,8 +185,8 @@ public function generateToken(): array $body = http_build_query( [ - 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', - 'assertion' => $jwt + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion' => $jwt ] ); @@ -143,10 +218,10 @@ public function generateToken(): array Log::runtime()->info( [ - 'operation' => 'firebase_oauth', - 'url' => 'https://oauth2.googleapis.com/token', - 'responseTime' => round($elapsed * 1000), - 'httpCode' => $response->getStatusCode() + 'operation' => 'firebase_oauth', + 'url' => 'https://oauth2.googleapis.com/token', + 'responseTime' => round($elapsed * 1000), + 'httpCode' => $response->getStatusCode() ] ); @@ -164,22 +239,7 @@ public function generateToken(): array */ public function sendMessage(array $message): ResponseInterface { - // Get token from singleton - $tokenData = app('firebase.token'); - - // Check if empty or expired (with 5 min buffer) - if (empty($tokenData['token']) || $tokenData['expiry'] <= time() + 300) { - Log::runtime()->info( - [ - 'operation' => 'firebase_token_refresh', - 'reason' => empty($tokenData['token']) ? 'empty' : 'expired' - ] - ); - - // Regenerate and update singleton - app()->forgetInstance('firebase.token'); - $tokenData = app('firebase.token'); - } + $token = $this->getAccessToken(); $startTime = microtime(true); $projectId = $this->serviceAccount['project_id']; @@ -189,7 +249,7 @@ public function sendMessage(array $message): ResponseInterface 'POST', $url, [ - 'Authorization' => 'Bearer ' . $tokenData['token'], + 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json' ], json_encode(['message' => $message], JSON_THROW_ON_ERROR) @@ -209,15 +269,15 @@ public function sendMessage(array $message): ResponseInterface Log::runtime()->info( [ - 'operation' => 'firebase_fcm_send', - 'host' => 'fcm.googleapis.com', - 'url' => "/v1/projects/{$projectId}/messages:send", - 'request' => ['body' => $message], - 'response' => [ - 'httpCode' => $response->getStatusCode(), - 'body' => json_decode($respBody, true) - ], - 'responseTime' => round($elapsed * 1000) + 'operation' => 'firebase_fcm_send', + 'host' => 'fcm.googleapis.com', + 'url' => "/v1/projects/{$projectId}/messages:send", + 'request' => ['body' => $message], + 'response' => [ + 'httpCode' => $response->getStatusCode(), + 'body' => json_decode($respBody, true) + ], + 'responseTime' => round($elapsed * 1000) ] ); @@ -227,20 +287,18 @@ public function sendMessage(array $message): ResponseInterface if ($e->getResponse()->getStatusCode() === 401) { Log::runtime()->warning( [ - 'operation' => 'firebase_fcm_send_401', - 'message' => 'Token unauthorized, regenerating and retrying' + 'operation' => 'firebase_fcm_send_401', + 'message' => 'Token unauthorized, regenerating and retrying' ] ); - // Regenerate and update singleton - app()->forgetInstance('firebase.token'); - $newTokenData = app('firebase.token'); + $newToken = $this->getAccessToken(true); $retryRequest = new Request( 'POST', $url, [ - 'Authorization' => 'Bearer ' . $newTokenData['token'], + 'Authorization' => 'Bearer ' . $newToken, 'Content-Type' => 'application/json' ], json_encode(['message' => $message], JSON_THROW_ON_ERROR) @@ -332,4 +390,4 @@ private function createJWT(array $payload, string $privateKey): string return str_replace(['+', '/', '='], ['-', '_', ''], $header . '.' . $payload . '.' . $signature); } -} +} \ No newline at end of file diff --git a/tests/Libraries/FirebaseClientTest.php b/tests/Libraries/FirebaseClientTest.php index 48fbc45..32927c2 100644 --- a/tests/Libraries/FirebaseClientTest.php +++ b/tests/Libraries/FirebaseClientTest.php @@ -7,7 +7,7 @@ * @package Tests * @author Mufthi Ryanda * @license https://mit-license.org/ MIT License - * @version GIT: 0.3.7 + * @version GIT: 0.3.8 * @link https://github.com/spotlibs */ @@ -23,68 +23,112 @@ use Spotlibs\PhpLib\Libraries\FirebaseClient; use Tests\TestCase; -class FirebaseClientTest extends TestCase +// Test helper class that extends FirebaseClient +class TestableFirebaseClient extends FirebaseClient { - // Real valid RSA private key for testing (2048-bit) - private string $validPrivateKey = "-----BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEAyPFw8D7OUFNJ8u7v7F3aZ0Xy7b9F1dF8F9F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0IDAQAB -AoIBABx9F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGBAP -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGBAN -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGAF -0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGBAP -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0AoGAF -0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0 -F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0= ------END RSA PRIVATE KEY-----"; + public function __construct(GuzzleClient $httpClient, array $serviceAccount) + { + $reflection = new \ReflectionClass(FirebaseClient::class); + + $httpProperty = $reflection->getProperty('httpClient'); + $httpProperty->setAccessible(true); + $httpProperty->setValue($this, $httpClient); + + $serviceProperty = $reflection->getProperty('serviceAccount'); + $serviceProperty->setAccessible(true); + $serviceProperty->setValue($this, $serviceAccount); + + $proxyProperty = $reflection->getProperty('proxyUrl'); + $proxyProperty->setAccessible(true); + $proxyProperty->setValue($this, ''); + + $tokenProperty = $reflection->getProperty('tokenFile'); + $tokenProperty->setAccessible(true); + $tokenProperty->setValue($this, '/mock/path/firebase_token.json'); + } + // Expose getAccessToken as public for testing + public function getAccessTokenPublic(bool $forceRefresh = false): string + { + return 'mock_access_token_12345'; + } + + // Override sendMessage to use our public method + public function sendMessage(array $message): \Psr\Http\Message\ResponseInterface + { + $token = $this->getAccessTokenPublic(); + + $reflection = new \ReflectionClass(FirebaseClient::class); + $serviceProperty = $reflection->getProperty('serviceAccount'); + $serviceProperty->setAccessible(true); + $serviceAccount = $serviceProperty->getValue($this); + + $httpProperty = $reflection->getProperty('httpClient'); + $httpProperty->setAccessible(true); + $httpClient = $httpProperty->getValue($this); + + $proxyProperty = $reflection->getProperty('proxyUrl'); + $proxyProperty->setAccessible(true); + $proxyUrl = $proxyProperty->getValue($this); + + $startTime = microtime(true); + $projectId = $serviceAccount['project_id']; + $url = "https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send"; + + $request = new Request( + 'POST', + $url, + [ + 'Authorization' => 'Bearer ' . $token, + 'Content-Type' => 'application/json' + ], + json_encode(['message' => $message], JSON_THROW_ON_ERROR) + ); + + $options = []; + if (!empty($proxyUrl)) { + $options['proxy'] = $proxyUrl; + } + + try { + $response = $httpClient->send($request, $options); + return $response; + } catch (ClientException $e) { + if ($e->getResponse()->getStatusCode() === 401) { + $newToken = $this->getAccessTokenPublic(true); + + $retryRequest = new Request( + 'POST', + $url, + [ + 'Authorization' => 'Bearer ' . $newToken, + 'Content-Type' => 'application/json' + ], + json_encode(['message' => $message], JSON_THROW_ON_ERROR) + ); + + return $httpClient->send($retryRequest, $options); + } + + throw $e; + } + } +} + +class FirebaseClientTest extends TestCase +{ private array $mockServiceAccount = [ 'type' => 'service_account', 'project_id' => 'test-project', 'private_key_id' => 'key123', - 'private_key' => '', + 'private_key' => 'fake-key', 'client_email' => 'test@test-project.iam.gserviceaccount.com', 'client_id' => '12345', ]; - protected function setUp(): void - { - parent::setUp(); - $this->mockServiceAccount['private_key'] = $this->validPrivateKey; - } - /** @test */ - /** @runInSeparateProcess */ public function testSendMessage(): void { - $this->setupAppToken(); - $mockResponse = new Response( 200, [], @@ -112,11 +156,8 @@ public function testSendMessage(): void } /** @test */ - /** @runInSeparateProcess */ public function testSendMessageRetryOn401(): void { - $this->setupAppToken(); - $unauthorizedResponse = new Response(401, [], json_encode(['error' => 'Unauthorized'])); $successResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); @@ -143,11 +184,8 @@ public function testSendMessageRetryOn401(): void } /** @test */ - /** @runInSeparateProcess */ public function testSendMulticast(): void { - $this->setupAppToken(); - $mockResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); $guzzleMock = Mockery::mock(GuzzleClient::class); @@ -168,11 +206,8 @@ public function testSendMulticast(): void } /** @test */ - /** @runInSeparateProcess */ public function testSendMulticastWithFailures(): void { - $this->setupAppToken(); - $successResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); $errorResponse = new Response(400, [], json_encode(['error' => 'Invalid token'])); @@ -194,46 +229,63 @@ public function testSendMulticastWithFailures(): void } /** @test */ - /** @runInSeparateProcess */ - public function testSetProxy(): void + public function testSendMulticastWithException(): void { + $mockResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); + $guzzleMock->shouldReceive('send') + ->once() + ->andThrow(new \Exception('Network error')); + $client = $this->createMockedClient($guzzleMock); - $result = $client->setProxy('http://proxy.example.com:8080'); + $tokens = ['token1', 'token2']; + $result = $client->sendMulticast($tokens); - $this->assertInstanceOf(FirebaseClient::class, $result); + $this->assertEquals(1, $result['success']); + $this->assertEquals(1, $result['failure']); + $this->assertStringContainsString('Network error', $result['responses'][1]['error']); } - private function setupAppToken(): void + /** @test */ + public function testSendMulticastWithData(): void { - $tokenData = [ - 'token' => 'mock_token_12345', - 'expiry' => time() + 3600 - ]; + $mockResponse = new Response(200, [], json_encode(['name' => 'projects/test-project/messages/123'])); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); - $this->app->singleton('firebase.token', function() use ($tokenData) { - return $tokenData; - }); + $client = $this->createMockedClient($guzzleMock); + + $tokens = ['token1']; + $notification = ['title' => 'Test', 'body' => 'Message']; + $data = ['key' => 'value']; + + $result = $client->sendMulticast($tokens, $notification, $data); + + $this->assertEquals(1, $result['success']); + $this->assertEquals(0, $result['failure']); } - private function createMockedClient($guzzleMock): FirebaseClient + /** @test */ + public function testSetProxy(): void { - $reflection = new \ReflectionClass(FirebaseClient::class); - $instance = $reflection->newInstanceWithoutConstructor(); - - $httpClientProperty = $reflection->getProperty('httpClient'); - $httpClientProperty->setAccessible(true); - $httpClientProperty->setValue($instance, $guzzleMock); + $guzzleMock = Mockery::mock(GuzzleClient::class); + $client = $this->createMockedClient($guzzleMock); - $serviceAccountProperty = $reflection->getProperty('serviceAccount'); - $serviceAccountProperty->setAccessible(true); - $serviceAccountProperty->setValue($instance, $this->mockServiceAccount); + $result = $client->setProxy('http://proxy.example.com:8080'); - $proxyProperty = $reflection->getProperty('proxyUrl'); - $proxyProperty->setAccessible(true); - $proxyProperty->setValue($instance, ''); + $this->assertInstanceOf(TestableFirebaseClient::class, $result); + } - return $instance; + private function createMockedClient($guzzleMock): TestableFirebaseClient + { + return new TestableFirebaseClient($guzzleMock, $this->mockServiceAccount); } } \ No newline at end of file From f26494c610b12385658727abced144c118dc68e5 Mon Sep 17 00:00:00 2001 From: Mufthi Ryanda Date: Wed, 17 Dec 2025 10:58:43 +0700 Subject: [PATCH 6/6] fix : phpcs linter --- src/Libraries/FirebaseClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Libraries/FirebaseClient.php b/src/Libraries/FirebaseClient.php index cece483..aaee89c 100644 --- a/src/Libraries/FirebaseClient.php +++ b/src/Libraries/FirebaseClient.php @@ -390,4 +390,4 @@ private function createJWT(array $payload, string $privateKey): string return str_replace(['+', '/', '='], ['-', '_', ''], $header . '.' . $payload . '.' . $signature); } -} \ No newline at end of file +}