diff --git a/src/Libraries/FirebaseClient.php b/src/Libraries/FirebaseClient.php index aaee89c..028c122 100644 --- a/src/Libraries/FirebaseClient.php +++ b/src/Libraries/FirebaseClient.php @@ -365,6 +365,60 @@ public function sendMulticast( return $results; } + /** + * Send FCM message to a topic + * + * @param string $topic Topic name (without /topics/ prefix) + * @param array $notification Notification payload (title, body) + * @param array $data Data payload + * + * @return ResponseInterface + * + * @throws \GuzzleHttp\Exception\GuzzleException On HTTP error + */ + public function sendToTopic( + string $topic, + array $notification = [], + array $data = [] + ): ResponseInterface { + $message = ['topic' => $topic]; + if (!empty($notification)) { + $message['notification'] = $notification; + } + if (!empty($data)) { + $message['data'] = $data; + } + + return $this->sendMessage($message); + } + + /** + * Send FCM message to a topic condition + * + * @param string $condition Topic condition expression (e.g. "'topicA' in topics && 'topicB' in topics") + * @param array $notification Notification payload (title, body) + * @param array $data Data payload + * + * @return ResponseInterface + * + * @throws \GuzzleHttp\Exception\GuzzleException On HTTP error + */ + public function sendToCondition( + string $condition, + array $notification = [], + array $data = [] + ): ResponseInterface { + $message = ['condition' => $condition]; + if (!empty($notification)) { + $message['notification'] = $notification; + } + if (!empty($data)) { + $message['data'] = $data; + } + + return $this->sendMessage($message); + } + /** * Generate JWT manually using OpenSSL * @@ -375,8 +429,8 @@ public function sendMulticast( */ private function createJWT(array $payload, string $privateKey): string { - $header = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); - $payload = base64_encode(json_encode($payload)); + $header = rtrim(strtr(base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])), '+/', '-_'), '='); + $payload = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '='); $signature = ''; openssl_sign( @@ -386,8 +440,8 @@ private function createJWT(array $payload, string $privateKey): string OPENSSL_ALGO_SHA256 ); - $signature = base64_encode($signature); + $signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '='); - return str_replace(['+', '/', '='], ['-', '_', ''], $header . '.' . $payload . '.' . $signature); + return $header . '.' . $payload . '.' . $signature; } } diff --git a/tests/Libraries/FirebaseClientTest.php b/tests/Libraries/FirebaseClientTest.php index 32927c2..5bf0201 100644 --- a/tests/Libraries/FirebaseClientTest.php +++ b/tests/Libraries/FirebaseClientTest.php @@ -273,6 +273,101 @@ public function testSendMulticastWithData(): void $this->assertEquals(0, $result['failure']); } + /** @test */ + public function testSendToTopic(): void + { + $mockResponse = new Response( + 200, + [], + json_encode(['name' => 'projects/test-project/messages/123']) + ); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); + + $client = $this->createMockedClient($guzzleMock); + + $response = $client->sendToTopic( + 'news', + ['title' => 'Breaking', 'body' => 'Something happened'], + ['key' => 'value'] + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** @test */ + public function testSendToTopicWithNotificationOnly(): void + { + $mockResponse = new Response( + 200, + [], + json_encode(['name' => 'projects/test-project/messages/123']) + ); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); + + $client = $this->createMockedClient($guzzleMock); + + $response = $client->sendToTopic('news', ['title' => 'Test', 'body' => 'Body']); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** @test */ + public function testSendToCondition(): void + { + $mockResponse = new Response( + 200, + [], + json_encode(['name' => 'projects/test-project/messages/123']) + ); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); + + $client = $this->createMockedClient($guzzleMock); + + $response = $client->sendToCondition( + "'news' in topics || 'alerts' in topics", + ['title' => 'Update', 'body' => 'New update available'], + ['version' => '2.0'] + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + + /** @test */ + public function testSendToConditionWithNotificationOnly(): void + { + $mockResponse = new Response( + 200, + [], + json_encode(['name' => 'projects/test-project/messages/123']) + ); + + $guzzleMock = Mockery::mock(GuzzleClient::class); + $guzzleMock->shouldReceive('send') + ->once() + ->andReturn($mockResponse); + + $client = $this->createMockedClient($guzzleMock); + + $response = $client->sendToCondition( + "'news' in topics && 'premium' in topics", + ['title' => 'Premium News', 'body' => 'Exclusive content'] + ); + + $this->assertEquals(200, $response->getStatusCode()); + } + /** @test */ public function testSetProxy(): void {