From caed3df95b8733d2f3d59d7d8a8b8eca512f0bce Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 23 Jun 2026 09:18:09 +0530 Subject: [PATCH 1/7] Add MSG91 metadata tracking --- src/Utopia/Messaging/Adapter/SMS/Msg91.php | 32 ++++++-- .../Adapter/SMS/Msg91/MetadataParameter.php | 21 +++++ src/Utopia/Messaging/Messages/SMS.php | 20 +++++ tests/Messaging/Adapter/SMS/Msg91Test.php | 80 +++++++++++++++++++ 4 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 src/Utopia/Messaging/Adapter/SMS/Msg91/MetadataParameter.php diff --git a/src/Utopia/Messaging/Adapter/SMS/Msg91.php b/src/Utopia/Messaging/Adapter/SMS/Msg91.php index 8c77907c..a99c3f69 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Msg91.php +++ b/src/Utopia/Messaging/Adapter/SMS/Msg91.php @@ -2,6 +2,7 @@ namespace Utopia\Messaging\Adapter\SMS; +use Utopia\Messaging\Adapter\SMS\Msg91\MetadataParameter; use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Messages\SMS as SMSMessage; use Utopia\Messaging\Response; @@ -51,6 +52,31 @@ protected function process(SMSMessage $message): array ]; } + $body = [ + 'sender' => $this->senderId, + 'template_id' => $this->templateId, + 'recipients' => $recipients, + ]; + + $metadata = $message->getMetadata() ?? []; + $metadata = \array_intersect_key($metadata, \array_flip(\array_column(MetadataParameter::cases(), 'value'))); + + foreach ([MetadataParameter::CRQID, MetadataParameter::UUID] as $parameter) { + $key = $parameter->value; + + if (!isset($metadata[$key])) { + continue; + } + + if (\strlen($metadata[$key]) > 80 || !\preg_match('/^[A-Za-z0-9_.-]+$/', $metadata[$key])) { + throw new \InvalidArgumentException("Msg91 {$key} metadata must be 80 characters or less and contain only alphanumeric characters, underscores, dots, or hyphens."); + } + } + + foreach ($metadata as $key => $value) { + $body[$key] = $value; + } + $response = new Response($this->getType()); $result = $this->request( method: 'POST', @@ -59,11 +85,7 @@ protected function process(SMSMessage $message): array 'Content-Type: application/json', 'Authkey: '. $this->authKey, ], - body: [ - 'sender' => $this->senderId, - 'template_id' => $this->templateId, - 'recipients' => $recipients, - ], + body: $body, ); if ($result['statusCode'] === 200) { diff --git a/src/Utopia/Messaging/Adapter/SMS/Msg91/MetadataParameter.php b/src/Utopia/Messaging/Adapter/SMS/Msg91/MetadataParameter.php new file mode 100644 index 00000000..7d897baf --- /dev/null +++ b/src/Utopia/Messaging/Adapter/SMS/Msg91/MetadataParameter.php @@ -0,0 +1,21 @@ + $to * @param array|null $attachments + * @param array|null $metadata */ public function __construct( private array $to, private string $content, private ?string $from = null, private ?array $attachments = null, + private ?array $metadata = null, ) { } @@ -46,6 +48,24 @@ public function getAttachments(): ?array return $this->attachments; } + /** + * @return array|null + */ + public function getMetadata(): ?array + { + return $this->metadata; + } + + /** + * @param array|null $metadata + */ + public function setMetadata(?array $metadata): self + { + $this->metadata = $metadata; + + return $this; + } + public function setOrigin(?string $origin): self { $this->origin = $origin; diff --git a/tests/Messaging/Adapter/SMS/Msg91Test.php b/tests/Messaging/Adapter/SMS/Msg91Test.php index 854bdeab..aa56ca51 100644 --- a/tests/Messaging/Adapter/SMS/Msg91Test.php +++ b/tests/Messaging/Adapter/SMS/Msg91Test.php @@ -21,4 +21,84 @@ public function testSendSMS(): void $this->assertResponse($response); } + + public function testSendSMSWithMetadata(): void + { + $sender = new Msg91TestAdapter('sender', 'auth', 'template'); + + $message = new SMS( + to: ['+911234567890'], + content: 'Test Content', + metadata: [ + 'clientId' => 'client-123', + 'CRQID' => 'request_123', + 'UUID' => 'uuid.123', + 'ignored' => 'value', + ], + ); + + $response = $sender->send($message); + + $this->assertResponse($response); + $this->assertEquals('client-123', $sender->body['clientId']); + $this->assertEquals('request_123', $sender->body['CRQID']); + $this->assertEquals('uuid.123', $sender->body['UUID']); + $this->assertArrayNotHasKey('ignored', $sender->body); + } + + public function testSendSMSWithInvalidMetadata(): void + { + $sender = new Msg91TestAdapter('sender', 'auth', 'template'); + + $message = new SMS( + to: ['+911234567890'], + content: 'Test Content', + metadata: [ + 'CRQID' => 'invalid value', + ], + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Msg91 CRQID metadata must be 80 characters or less'); + + $sender->send($message); + } +} + +class Msg91TestAdapter extends Msg91 +{ + /** + * @var array + */ + public array $body = []; + + /** + * @param array $headers + * @param array|null $body + * @return array{ + * url: string, + * statusCode: int, + * response: array|string|null, + * headers: array, + * error: string|null + * } + */ + protected function request( + string $method, + string $url, + array $headers = [], + ?array $body = null, + int $timeout = 30, + int $connectTimeout = 10 + ): array { + $this->body = $body ?? []; + + return [ + 'url' => $url, + 'statusCode' => 200, + 'response' => [], + 'headers' => [], + 'error' => null, + ]; + } } From cac2597d43714a517cdbae5f6975d1815725b163 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 23 Jun 2026 09:27:58 +0530 Subject: [PATCH 2/7] Address MSG91 metadata review feedback --- src/Utopia/Messaging/Adapter/SMS/GEOSMS.php | 3 +- src/Utopia/Messaging/Adapter/SMS/Msg91.php | 43 +++++++++++++-------- tests/Messaging/Adapter/SMS/GEOSMSTest.php | 32 +++++++++++++++ tests/Messaging/Adapter/SMS/Msg91Test.php | 33 +++++++++++++++- 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php index b3ec5c19..d13302b3 100644 --- a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php +++ b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php @@ -92,7 +92,8 @@ protected function process(SMS $message): array to: $nextRecipients, content: $message->getContent(), from: $message->getFrom(), - attachments: $message->getAttachments() + attachments: $message->getAttachments(), + metadata: $message->getMetadata() ))->setOrigin($message->getOrigin()) ); } catch (\Exception $e) { diff --git a/src/Utopia/Messaging/Adapter/SMS/Msg91.php b/src/Utopia/Messaging/Adapter/SMS/Msg91.php index a99c3f69..969d8172 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Msg91.php +++ b/src/Utopia/Messaging/Adapter/SMS/Msg91.php @@ -43,28 +43,19 @@ public function getMaxMessagesPerRequest(): int */ protected function process(SMSMessage $message): array { - $recipients = []; - foreach ($message->getTo() as $recipient) { - $recipients[] = [ - 'mobiles' => \ltrim($recipient, '+'), - 'content' => $message->getContent(), - 'otp' => $message->getContent(), - ]; - } - - $body = [ - 'sender' => $this->senderId, - 'template_id' => $this->templateId, - 'recipients' => $recipients, - ]; - $metadata = $message->getMetadata() ?? []; $metadata = \array_intersect_key($metadata, \array_flip(\array_column(MetadataParameter::cases(), 'value'))); + foreach ($metadata as $key => $value) { + if (!\is_string($value)) { + throw new \InvalidArgumentException("Msg91 {$key} metadata must be a string."); + } + } + foreach ([MetadataParameter::CRQID, MetadataParameter::UUID] as $parameter) { $key = $parameter->value; - if (!isset($metadata[$key])) { + if (!\array_key_exists($key, $metadata)) { continue; } @@ -73,6 +64,26 @@ protected function process(SMSMessage $message): array } } + $recipientMetadata = \array_intersect_key($metadata, \array_flip([ + MetadataParameter::CRQID->value, + MetadataParameter::UUID->value, + ])); + + $recipients = []; + foreach ($message->getTo() as $recipient) { + $recipients[] = [ + 'mobiles' => \ltrim($recipient, '+'), + 'content' => $message->getContent(), + 'otp' => $message->getContent(), + ] + $recipientMetadata; + } + + $body = [ + 'sender' => $this->senderId, + 'template_id' => $this->templateId, + 'recipients' => $recipients, + ]; + foreach ($metadata as $key => $value) { $body[$key] = $value; } diff --git a/tests/Messaging/Adapter/SMS/GEOSMSTest.php b/tests/Messaging/Adapter/SMS/GEOSMSTest.php index 82633336..a1d96beb 100644 --- a/tests/Messaging/Adapter/SMS/GEOSMSTest.php +++ b/tests/Messaging/Adapter/SMS/GEOSMSTest.php @@ -111,4 +111,36 @@ public function testSendSMSUsingGroupedLocalAdapter(): void $this->assertEquals(1, count($result)); $this->assertEquals('success', $result['local']['results'][0]['status']); } + + public function testSendSMSForwardsMetadata(): void + { + $metadata = [ + 'clientId' => 'client-123', + 'CRQID' => 'request_123', + 'UUID' => 'uuid.123', + ]; + + $defaultAdapterMock = $this->createMock(SMSAdapter::class); + $defaultAdapterMock->method('getName')->willReturn('default'); + $defaultAdapterMock + ->expects($this->once()) + ->method('send') + ->with($this->callback(function (SMS $message) use ($metadata): bool { + return $message->getMetadata() === $metadata; + })) + ->willReturn(['results' => [['status' => 'success']]]); + + $adapter = new GEOSMS($defaultAdapterMock); + + $message = new SMS( + to: ['+11234567890'], + content: 'Test Content', + metadata: $metadata + ); + + $result = $adapter->send($message); + + $this->assertEquals(1, count($result)); + $this->assertEquals('success', $result['default']['results'][0]['status']); + } } diff --git a/tests/Messaging/Adapter/SMS/Msg91Test.php b/tests/Messaging/Adapter/SMS/Msg91Test.php index aa56ca51..91c8e95a 100644 --- a/tests/Messaging/Adapter/SMS/Msg91Test.php +++ b/tests/Messaging/Adapter/SMS/Msg91Test.php @@ -27,7 +27,7 @@ public function testSendSMSWithMetadata(): void $sender = new Msg91TestAdapter('sender', 'auth', 'template'); $message = new SMS( - to: ['+911234567890'], + to: ['+911234567890', '+911234567891'], content: 'Test Content', metadata: [ 'clientId' => 'client-123', @@ -39,10 +39,18 @@ public function testSendSMSWithMetadata(): void $response = $sender->send($message); - $this->assertResponse($response); + $this->assertEquals(2, $response['deliveredTo'], \var_export($response, true)); + $this->assertEquals('', $response['results'][0]['error'], \var_export($response, true)); + $this->assertEquals('success', $response['results'][0]['status'], \var_export($response, true)); + $this->assertEquals('', $response['results'][1]['error'], \var_export($response, true)); + $this->assertEquals('success', $response['results'][1]['status'], \var_export($response, true)); $this->assertEquals('client-123', $sender->body['clientId']); $this->assertEquals('request_123', $sender->body['CRQID']); $this->assertEquals('uuid.123', $sender->body['UUID']); + $this->assertEquals('request_123', $sender->body['recipients'][0]['CRQID']); + $this->assertEquals('uuid.123', $sender->body['recipients'][0]['UUID']); + $this->assertEquals('request_123', $sender->body['recipients'][1]['CRQID']); + $this->assertEquals('uuid.123', $sender->body['recipients'][1]['UUID']); $this->assertArrayNotHasKey('ignored', $sender->body); } @@ -63,6 +71,27 @@ public function testSendSMSWithInvalidMetadata(): void $sender->send($message); } + + public function testSendSMSWithNullMetadata(): void + { + $sender = new Msg91TestAdapter('sender', 'auth', 'template'); + + /** @var array $metadata */ + $metadata = [ + 'CRQID' => null, + ]; + + $message = new SMS( + to: ['+911234567890'], + content: 'Test Content', + metadata: $metadata, + ); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Msg91 CRQID metadata must be a string'); + + $sender->send($message); + } } class Msg91TestAdapter extends Msg91 From 08728c06c00f74bbf57217610c78053268ce56f4 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 23 Jun 2026 09:35:09 +0530 Subject: [PATCH 3/7] Avoid duplicate GEOSMS tracking metadata --- src/Utopia/Messaging/Adapter/SMS/GEOSMS.php | 26 ++++++++--- tests/Messaging/Adapter/SMS/GEOSMSTest.php | 48 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php index d13302b3..3e03232c 100644 --- a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php +++ b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php @@ -82,29 +82,41 @@ protected function process(SMS $message): array { $results = []; $recipients = $message->getTo(); + $batches = []; do { [$nextRecipients, $nextAdapter] = $this->getNextRecipientsAndAdapter($recipients); + $batches[] = [ + 'recipients' => $nextRecipients, + 'adapter' => $nextAdapter, + ]; + $recipients = \array_diff($recipients, $nextRecipients); + } while (count($recipients) > 0); + + $metadata = $message->getMetadata(); + if (\count($batches) > 1 && $metadata !== null) { + unset($metadata['CRQID'], $metadata['UUID']); + } + + foreach ($batches as $batch) { try { - $results[$nextAdapter->getName()] = $nextAdapter->send( + $results[$batch['adapter']->getName()] = $batch['adapter']->send( (new SMS( - to: $nextRecipients, + to: $batch['recipients'], content: $message->getContent(), from: $message->getFrom(), attachments: $message->getAttachments(), - metadata: $message->getMetadata() + metadata: $metadata ))->setOrigin($message->getOrigin()) ); } catch (\Exception $e) { - $results[$nextAdapter->getName()] = [ + $results[$batch['adapter']->getName()] = [ 'type' => 'error', 'message' => $e->getMessage(), ]; } - - $recipients = \array_diff($recipients, $nextRecipients); - } while (count($recipients) > 0); + } return $results; } diff --git a/tests/Messaging/Adapter/SMS/GEOSMSTest.php b/tests/Messaging/Adapter/SMS/GEOSMSTest.php index a1d96beb..63774164 100644 --- a/tests/Messaging/Adapter/SMS/GEOSMSTest.php +++ b/tests/Messaging/Adapter/SMS/GEOSMSTest.php @@ -143,4 +143,52 @@ public function testSendSMSForwardsMetadata(): void $this->assertEquals(1, count($result)); $this->assertEquals('success', $result['default']['results'][0]['status']); } + + public function testSendSMSRemovesRequestTrackingMetadataWhenSplit(): void + { + $metadata = [ + 'clientId' => 'client-123', + 'CRQID' => 'request_123', + 'UUID' => 'uuid.123', + ]; + + $expectedMetadata = [ + 'clientId' => 'client-123', + ]; + + $defaultAdapterMock = $this->createMock(SMSAdapter::class); + $defaultAdapterMock->method('getName')->willReturn('default'); + $defaultAdapterMock + ->expects($this->once()) + ->method('send') + ->with($this->callback(function (SMS $message) use ($expectedMetadata): bool { + return $message->getMetadata() === $expectedMetadata; + })) + ->willReturn(['results' => [['status' => 'success']]]); + + $localAdapterMock = $this->createMock(SMSAdapter::class); + $localAdapterMock->method('getName')->willReturn('local'); + $localAdapterMock + ->expects($this->once()) + ->method('send') + ->with($this->callback(function (SMS $message) use ($expectedMetadata): bool { + return $message->getMetadata() === $expectedMetadata; + })) + ->willReturn(['results' => [['status' => 'success']]]); + + $adapter = new GEOSMS($defaultAdapterMock); + $adapter->setLocal(CallingCode::INDIA, $localAdapterMock); + + $message = new SMS( + to: ['+911234567890', '+11234567890'], + content: 'Test Content', + metadata: $metadata + ); + + $result = $adapter->send($message); + + $this->assertEquals(2, count($result)); + $this->assertEquals('success', $result['local']['results'][0]['status']); + $this->assertEquals('success', $result['default']['results'][0]['status']); + } } From 53497b0bec656c54eab8960c393d5d0964cebf00 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 23 Jun 2026 09:36:44 +0530 Subject: [PATCH 4/7] Compact metadata tests --- tests/Messaging/Adapter/SMS/GEOSMSTest.php | 11 +------- tests/Messaging/Adapter/SMS/Msg91Test.php | 30 ++++++++++------------ 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/tests/Messaging/Adapter/SMS/GEOSMSTest.php b/tests/Messaging/Adapter/SMS/GEOSMSTest.php index 63774164..0f4f4296 100644 --- a/tests/Messaging/Adapter/SMS/GEOSMSTest.php +++ b/tests/Messaging/Adapter/SMS/GEOSMSTest.php @@ -112,7 +112,7 @@ public function testSendSMSUsingGroupedLocalAdapter(): void $this->assertEquals('success', $result['local']['results'][0]['status']); } - public function testSendSMSForwardsMetadata(): void + public function testSendSMSHandlesMetadata(): void { $metadata = [ 'clientId' => 'client-123', @@ -142,15 +142,6 @@ public function testSendSMSForwardsMetadata(): void $this->assertEquals(1, count($result)); $this->assertEquals('success', $result['default']['results'][0]['status']); - } - - public function testSendSMSRemovesRequestTrackingMetadataWhenSplit(): void - { - $metadata = [ - 'clientId' => 'client-123', - 'CRQID' => 'request_123', - 'UUID' => 'uuid.123', - ]; $expectedMetadata = [ 'clientId' => 'client-123', diff --git a/tests/Messaging/Adapter/SMS/Msg91Test.php b/tests/Messaging/Adapter/SMS/Msg91Test.php index 91c8e95a..0c6a7049 100644 --- a/tests/Messaging/Adapter/SMS/Msg91Test.php +++ b/tests/Messaging/Adapter/SMS/Msg91Test.php @@ -22,7 +22,7 @@ public function testSendSMS(): void $this->assertResponse($response); } - public function testSendSMSWithMetadata(): void + public function testSendSMSHandlesMetadata(): void { $sender = new Msg91TestAdapter('sender', 'auth', 'template'); @@ -52,10 +52,7 @@ public function testSendSMSWithMetadata(): void $this->assertEquals('request_123', $sender->body['recipients'][1]['CRQID']); $this->assertEquals('uuid.123', $sender->body['recipients'][1]['UUID']); $this->assertArrayNotHasKey('ignored', $sender->body); - } - public function testSendSMSWithInvalidMetadata(): void - { $sender = new Msg91TestAdapter('sender', 'auth', 'template'); $message = new SMS( @@ -66,15 +63,12 @@ public function testSendSMSWithInvalidMetadata(): void ], ); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Msg91 CRQID metadata must be 80 characters or less'); - - $sender->send($message); - } - - public function testSendSMSWithNullMetadata(): void - { - $sender = new Msg91TestAdapter('sender', 'auth', 'template'); + try { + $sender->send($message); + $this->fail('Expected invalid MSG91 metadata to throw.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Msg91 CRQID metadata must be 80 characters or less', $e->getMessage()); + } /** @var array $metadata */ $metadata = [ @@ -87,10 +81,12 @@ public function testSendSMSWithNullMetadata(): void metadata: $metadata, ); - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Msg91 CRQID metadata must be a string'); - - $sender->send($message); + try { + $sender->send($message); + $this->fail('Expected null MSG91 metadata to throw.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Msg91 CRQID metadata must be a string', $e->getMessage()); + } } } From 5fcff37ad657db309e6cba2c4220bc91e01cffa7 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 23 Jun 2026 09:44:25 +0530 Subject: [PATCH 5/7] Refine MSG91 tracking metadata handling --- src/Utopia/Messaging/Adapter/SMS/GEOSMS.php | 17 ++++++++++++----- src/Utopia/Messaging/Adapter/SMS/Msg91.php | 7 +------ tests/Messaging/Adapter/SMS/GEOSMSTest.php | 18 +++++++++++++----- tests/Messaging/Adapter/SMS/Msg91Test.php | 8 ++++---- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php index 3e03232c..bf7f7828 100644 --- a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php +++ b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php @@ -94,12 +94,19 @@ protected function process(SMS $message): array $recipients = \array_diff($recipients, $nextRecipients); } while (count($recipients) > 0); - $metadata = $message->getMetadata(); - if (\count($batches) > 1 && $metadata !== null) { - unset($metadata['CRQID'], $metadata['UUID']); - } + foreach ($batches as $index => $batch) { + $metadata = $message->getMetadata(); + if (\count($batches) > 1 && $metadata !== null) { + foreach (['CRQID', 'UUID'] as $key) { + if (!isset($metadata[$key])) { + continue; + } + + $suffix = '-'.($index + 1); + $metadata[$key] = \substr($metadata[$key], 0, 80 - \strlen($suffix)).$suffix; + } + } - foreach ($batches as $batch) { try { $results[$batch['adapter']->getName()] = $batch['adapter']->send( (new SMS( diff --git a/src/Utopia/Messaging/Adapter/SMS/Msg91.php b/src/Utopia/Messaging/Adapter/SMS/Msg91.php index 969d8172..660ccfe4 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Msg91.php +++ b/src/Utopia/Messaging/Adapter/SMS/Msg91.php @@ -64,18 +64,13 @@ protected function process(SMSMessage $message): array } } - $recipientMetadata = \array_intersect_key($metadata, \array_flip([ - MetadataParameter::CRQID->value, - MetadataParameter::UUID->value, - ])); - $recipients = []; foreach ($message->getTo() as $recipient) { $recipients[] = [ 'mobiles' => \ltrim($recipient, '+'), 'content' => $message->getContent(), 'otp' => $message->getContent(), - ] + $recipientMetadata; + ]; } $body = [ diff --git a/tests/Messaging/Adapter/SMS/GEOSMSTest.php b/tests/Messaging/Adapter/SMS/GEOSMSTest.php index 0f4f4296..d2f3c8c2 100644 --- a/tests/Messaging/Adapter/SMS/GEOSMSTest.php +++ b/tests/Messaging/Adapter/SMS/GEOSMSTest.php @@ -143,8 +143,16 @@ public function testSendSMSHandlesMetadata(): void $this->assertEquals(1, count($result)); $this->assertEquals('success', $result['default']['results'][0]['status']); - $expectedMetadata = [ + $defaultMetadata = [ 'clientId' => 'client-123', + 'CRQID' => 'request_123-2', + 'UUID' => 'uuid.123-2', + ]; + + $localMetadata = [ + 'clientId' => 'client-123', + 'CRQID' => 'request_123-1', + 'UUID' => 'uuid.123-1', ]; $defaultAdapterMock = $this->createMock(SMSAdapter::class); @@ -152,8 +160,8 @@ public function testSendSMSHandlesMetadata(): void $defaultAdapterMock ->expects($this->once()) ->method('send') - ->with($this->callback(function (SMS $message) use ($expectedMetadata): bool { - return $message->getMetadata() === $expectedMetadata; + ->with($this->callback(function (SMS $message) use ($defaultMetadata): bool { + return $message->getMetadata() === $defaultMetadata; })) ->willReturn(['results' => [['status' => 'success']]]); @@ -162,8 +170,8 @@ public function testSendSMSHandlesMetadata(): void $localAdapterMock ->expects($this->once()) ->method('send') - ->with($this->callback(function (SMS $message) use ($expectedMetadata): bool { - return $message->getMetadata() === $expectedMetadata; + ->with($this->callback(function (SMS $message) use ($localMetadata): bool { + return $message->getMetadata() === $localMetadata; })) ->willReturn(['results' => [['status' => 'success']]]); diff --git a/tests/Messaging/Adapter/SMS/Msg91Test.php b/tests/Messaging/Adapter/SMS/Msg91Test.php index 0c6a7049..3dff601f 100644 --- a/tests/Messaging/Adapter/SMS/Msg91Test.php +++ b/tests/Messaging/Adapter/SMS/Msg91Test.php @@ -47,10 +47,10 @@ public function testSendSMSHandlesMetadata(): void $this->assertEquals('client-123', $sender->body['clientId']); $this->assertEquals('request_123', $sender->body['CRQID']); $this->assertEquals('uuid.123', $sender->body['UUID']); - $this->assertEquals('request_123', $sender->body['recipients'][0]['CRQID']); - $this->assertEquals('uuid.123', $sender->body['recipients'][0]['UUID']); - $this->assertEquals('request_123', $sender->body['recipients'][1]['CRQID']); - $this->assertEquals('uuid.123', $sender->body['recipients'][1]['UUID']); + $this->assertArrayNotHasKey('CRQID', $sender->body['recipients'][0]); + $this->assertArrayNotHasKey('UUID', $sender->body['recipients'][0]); + $this->assertArrayNotHasKey('CRQID', $sender->body['recipients'][1]); + $this->assertArrayNotHasKey('UUID', $sender->body['recipients'][1]); $this->assertArrayNotHasKey('ignored', $sender->body); $sender = new Msg91TestAdapter('sender', 'auth', 'template'); From 740b7956462ac4fb10d64e7fac114207de5e9924 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 23 Jun 2026 09:52:30 +0530 Subject: [PATCH 6/7] Validate GEOSMS tracking metadata before suffixing --- src/Utopia/Messaging/Adapter/SMS/GEOSMS.php | 16 ++++++++-- tests/Messaging/Adapter/SMS/GEOSMSTest.php | 33 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php index bf7f7828..e88d8a57 100644 --- a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php +++ b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php @@ -4,6 +4,7 @@ use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Adapter\SMS\GEOSMS\CallingCode; +use Utopia\Messaging\Adapter\SMS\Msg91\MetadataParameter; use Utopia\Messaging\Messages\SMS; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Telemetry\Adapter\None as NoTelemetry; @@ -95,13 +96,24 @@ protected function process(SMS $message): array } while (count($recipients) > 0); foreach ($batches as $index => $batch) { + /** @var array|null $metadata */ $metadata = $message->getMetadata(); if (\count($batches) > 1 && $metadata !== null) { - foreach (['CRQID', 'UUID'] as $key) { - if (!isset($metadata[$key])) { + foreach ([MetadataParameter::CRQID, MetadataParameter::UUID] as $parameter) { + $key = $parameter->value; + + if (!\array_key_exists($key, $metadata)) { continue; } + if (!\is_string($metadata[$key])) { + throw new \InvalidArgumentException("Msg91 {$key} metadata must be a string."); + } + + if (\strlen($metadata[$key]) > 80 || !\preg_match('/^[A-Za-z0-9_.-]+$/', $metadata[$key])) { + throw new \InvalidArgumentException("Msg91 {$key} metadata must be 80 characters or less and contain only alphanumeric characters, underscores, dots, or hyphens."); + } + $suffix = '-'.($index + 1); $metadata[$key] = \substr($metadata[$key], 0, 80 - \strlen($suffix)).$suffix; } diff --git a/tests/Messaging/Adapter/SMS/GEOSMSTest.php b/tests/Messaging/Adapter/SMS/GEOSMSTest.php index d2f3c8c2..893d87d6 100644 --- a/tests/Messaging/Adapter/SMS/GEOSMSTest.php +++ b/tests/Messaging/Adapter/SMS/GEOSMSTest.php @@ -189,5 +189,38 @@ public function testSendSMSHandlesMetadata(): void $this->assertEquals(2, count($result)); $this->assertEquals('success', $result['local']['results'][0]['status']); $this->assertEquals('success', $result['default']['results'][0]['status']); + + /** @var array $invalidMetadata */ + $invalidMetadata = [ + 'CRQID' => [], + ]; + + $message = new SMS( + to: ['+911234567890', '+11234567890'], + content: 'Test Content', + metadata: $invalidMetadata + ); + + try { + $adapter->send($message); + $this->fail('Expected invalid GEOSMS metadata to throw.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Msg91 CRQID metadata must be a string', $e->getMessage()); + } + + $message = new SMS( + to: ['+911234567890', '+11234567890'], + content: 'Test Content', + metadata: [ + 'CRQID' => '', + ] + ); + + try { + $adapter->send($message); + $this->fail('Expected empty GEOSMS metadata to throw.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Msg91 CRQID metadata must be 80 characters or less', $e->getMessage()); + } } } From 61d15a48e2fce2cb5e5c9343563f510bcd3bdc3c Mon Sep 17 00:00:00 2001 From: deepshekhardas Date: Mon, 13 Jul 2026 10:05:38 +0530 Subject: [PATCH 7/7] feat: add email adapter DSN parsing --- README.md | 42 +++ src/Utopia/Messaging/Adapter/Email.php | 177 ++++++++++ src/Utopia/Messaging/Messenger.php | 131 +++++++ tests/Messaging/Adapter/Email/DsnTest.php | 131 +++++++ tests/Messaging/MessengerTest.php | 395 ++++++++++++++++++++++ 5 files changed, 876 insertions(+) create mode 100644 src/Utopia/Messaging/Messenger.php create mode 100644 tests/Messaging/Adapter/Email/DsnTest.php create mode 100644 tests/Messaging/MessengerTest.php diff --git a/README.md b/README.md index f107048c..ecf4c299 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,48 @@ $messaging = new FCM('YOUR_SERVICE_ACCOUNT_JSON'); $messaging->send($message); ``` +You can also create email adapters from a DSN: + +```php +send($message); +``` + +The `Messenger` class accepts multiple adapters and tries them in order. It stops at the first successful response and only throws an exception if all adapters fail. + ## Adapters > Want to implement any of the missing adapters or have an idea for another? We would love to hear from you! Please check out our [contribution guide](./CONTRIBUTING.md) and [new adapter guide](./docs/add-new-adapter.md) for more information. diff --git a/src/Utopia/Messaging/Adapter/Email.php b/src/Utopia/Messaging/Adapter/Email.php index 39928ecd..b8d9b542 100644 --- a/src/Utopia/Messaging/Adapter/Email.php +++ b/src/Utopia/Messaging/Adapter/Email.php @@ -8,6 +8,7 @@ abstract class Email extends Adapter { protected const TYPE = 'email'; + protected const MESSAGE_TYPE = EmailMessage::class; protected const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25MB @@ -22,6 +23,37 @@ public function getMessageType(): string return static::MESSAGE_TYPE; } + /** + * Create an email adapter from a DSN string. + * + * Supported schemes: smtp, smtps, resend, sendgrid, mailgun. + * + * @throws \InvalidArgumentException + */ + public static function fromDsn(string $dsn): self + { + $parts = \parse_url($dsn); + + if ($parts === false || empty($parts['scheme'])) { + throw new \InvalidArgumentException('Invalid email DSN.'); + } + + $scheme = \strtolower($parts['scheme']); + $query = []; + + if (isset($parts['query'])) { + \parse_str($parts['query'], $query); + } + + return match ($scheme) { + 'smtp', 'smtps' => self::createSmtpAdapter($parts, $query, $scheme), + 'resend' => self::createApiKeyAdapter($parts, Email\Resend::class, 'Resend'), + 'sendgrid' => self::createApiKeyAdapter($parts, Email\Sendgrid::class, 'Sendgrid'), + 'mailgun' => self::createMailgunAdapter($parts, $query), + default => throw new \InvalidArgumentException('Unsupported email DSN scheme "'.$scheme.'".'), + }; + } + /** * Process an email message. * @@ -30,4 +62,149 @@ public function getMessageType(): string * @throws \Exception */ abstract protected function process(EmailMessage $message): array; + + /** + * @param array $parts + * @param array $query + */ + private static function createSmtpAdapter(array $parts, array $query, string $scheme): self + { + $host = self::decodeUrlComponent($parts['host'] ?? null); + + if ($host === null || $host === '') { + throw new \InvalidArgumentException('SMTP DSN must include a host.'); + } + + $port = self::parseIntOption( + value: $query['port'] ?? ($parts['port'] ?? ($scheme === 'smtps' ? 465 : 25)), + option: 'port' + ); + + $smtpSecure = self::parseSmtpSecureOption($query['secure'] ?? ($scheme === 'smtps' ? 'ssl' : '')); + $smtpAutoTLS = self::parseBoolOption($query['autotls'] ?? false, 'autotls'); + $timeout = self::parseIntOption($query['timeout'] ?? 30, 'timeout'); + $keepAlive = self::parseBoolOption($query['keepalive'] ?? false, 'keepalive'); + $timelimit = self::parseIntOption($query['timelimit'] ?? 30, 'timelimit'); + $xMailer = self::parseStringOption($query['xmailer'] ?? ''); + + return new Email\SMTP( + host: $host, + port: $port, + username: self::decodeUrlComponent($parts['user'] ?? null) ?? '', + password: self::decodeUrlComponent($parts['pass'] ?? null) ?? '', + smtpSecure: $smtpSecure, + smtpAutoTLS: $smtpAutoTLS, + xMailer: $xMailer, + timeout: $timeout, + keepAlive: $keepAlive, + timelimit: $timelimit, + ); + } + + /** + * @param array $parts + * @param class-string $adapterClass + */ + private static function createApiKeyAdapter(array $parts, string $adapterClass, string $adapterName): self + { + $apiKey = self::decodeUrlComponent($parts['user'] ?? null) + ?? self::decodeUrlComponent($parts['pass'] ?? null); + + if ($apiKey === null || $apiKey === '') { + throw new \InvalidArgumentException($adapterName.' DSN must include an API key.'); + } + + return new $adapterClass($apiKey); + } + + /** + * @param array $parts + * @param array $query + */ + private static function createMailgunAdapter(array $parts, array $query): self + { + $apiKey = self::decodeUrlComponent($parts['user'] ?? null) + ?? self::decodeUrlComponent($parts['pass'] ?? null); + + if ($apiKey === null || $apiKey === '') { + throw new \InvalidArgumentException('Mailgun DSN must include an API key.'); + } + + $domain = self::decodeUrlComponent($parts['host'] ?? null); + + if ($domain === null || $domain === '') { + throw new \InvalidArgumentException('Mailgun DSN must include a domain.'); + } + + return new Email\Mailgun( + apiKey: $apiKey, + domain: $domain, + isEU: self::parseBoolOption($query['eu'] ?? false, 'eu'), + ); + } + + private static function decodeUrlComponent(mixed $value): ?string + { + if (! \is_string($value) || $value === '') { + return null; + } + + return \rawurldecode($value); + } + + private static function parseStringOption(mixed $value): string + { + if (! \is_string($value)) { + throw new \InvalidArgumentException('Expected string query parameter value.'); + } + + return $value; + } + + private static function parseSmtpSecureOption(mixed $value): string + { + if (! \is_string($value)) { + throw new \InvalidArgumentException('Invalid SMTP "secure" option. Expected "", "ssl", or "tls".'); + } + + $value = \strtolower($value); + + if (! \in_array($value, ['', 'ssl', 'tls'], true)) { + throw new \InvalidArgumentException('Invalid SMTP "secure" option. Expected "", "ssl", or "tls".'); + } + + return $value; + } + + private static function parseBoolOption(mixed $value, string $option): bool + { + if (\is_bool($value)) { + return $value; + } + + if (! \is_string($value) && ! \is_int($value)) { + throw new \InvalidArgumentException('Invalid "'.$option.'" option. Expected boolean-like value.'); + } + + $normalized = \filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($normalized === null) { + throw new \InvalidArgumentException('Invalid "'.$option.'" option. Expected boolean-like value.'); + } + + return $normalized; + } + + private static function parseIntOption(mixed $value, string $option): int + { + if (\is_int($value)) { + return $value; + } + + if (! \is_string($value) || $value === '' || ! \ctype_digit($value)) { + throw new \InvalidArgumentException('Invalid "'.$option.'" option. Expected integer value.'); + } + + return (int) $value; + } } diff --git a/src/Utopia/Messaging/Messenger.php b/src/Utopia/Messaging/Messenger.php new file mode 100644 index 00000000..1b7f2cb4 --- /dev/null +++ b/src/Utopia/Messaging/Messenger.php @@ -0,0 +1,131 @@ + $adapter) { + if (! $adapter instanceof Adapter) { + throw new \InvalidArgumentException( + 'All elements must be instances of Adapter, but element ' + .$index + .' is ' + .\get_debug_type($adapter) + .'.' + ); + } + } + + $this->validateAdapters($adapters); + + $this->adapters = $adapters; + } + + public function send(Message $message): array + { + $errors = []; + $messageType = $this->adapters[0]->getMessageType(); + + if (! \is_a($message, $messageType)) { + throw new \Exception( + 'Invalid message type. Expected "' + .$messageType + .'", got "' + .\get_class($message) + .'".' + ); + } + + foreach ($this->adapters as $index => $adapter) { + try { + return $adapter->send($message); + } catch (\Exception $e) { + $errors[] = $adapter->getName() + .' (adapter ' + .($index + 1) + .'): ' + .$e->getMessage(); + } + } + + $adapterCount = \count($this->adapters); + $adapterLabel = $adapterCount === 1 ? 'adapter' : 'adapters'; + + throw new \Exception( + 'All ' + .$adapterCount + .' ' + .$adapterLabel + ." failed:\n" + .\implode("\n", $errors) + ); + } + + public function getMessageType(): string + { + return $this->adapters[0]->getMessageType(); + } + + public function getType(): string + { + return $this->adapters[0]->getType(); + } + + public function getMaxMessagesPerRequest(): int + { + return array_reduce( + $this->adapters, + fn ($min, $adapter) => min($min, $adapter->getMaxMessagesPerRequest()), + PHP_INT_MAX + ); + } + + private function validateAdapters(array $adapters): void + { + $firstAdapter = $adapters[0]; + $expectedType = $firstAdapter->getType(); + $expectedMessageType = $firstAdapter->getMessageType(); + + foreach (\array_slice($adapters, 1, preserve_keys: true) as $index => $adapter) { + if ($adapter->getType() !== $expectedType) { + throw new \InvalidArgumentException( + 'All adapters must be of the same type. Expected "' + .$expectedType + .'", but adapter ' + .($index + 1) + .' (' + .$adapter->getName() + .') has type "' + .$adapter->getType() + .'".' + ); + } + + if ($adapter->getMessageType() !== $expectedMessageType) { + throw new \InvalidArgumentException( + 'All adapters must support the same message type. Expected "' + .$expectedMessageType + .'", but adapter ' + .($index + 1) + .' (' + .$adapter->getName() + .') supports "' + .$adapter->getMessageType() + .'".' + ); + } + } + } +} diff --git a/tests/Messaging/Adapter/Email/DsnTest.php b/tests/Messaging/Adapter/Email/DsnTest.php new file mode 100644 index 00000000..59660d39 --- /dev/null +++ b/tests/Messaging/Adapter/Email/DsnTest.php @@ -0,0 +1,131 @@ +assertInstanceOf(SMTP::class, $adapter); + $this->assertSame('mail.example.com', $this->readProperty($adapter, 'host')); + $this->assertSame(587, $this->readProperty($adapter, 'port')); + $this->assertSame('user', $this->readProperty($adapter, 'username')); + $this->assertSame('pass', $this->readProperty($adapter, 'password')); + $this->assertSame('tls', $this->readProperty($adapter, 'smtpSecure')); + $this->assertTrue($this->readProperty($adapter, 'smtpAutoTLS')); + $this->assertSame('Appwrite', $this->readProperty($adapter, 'xMailer')); + $this->assertSame(60, $this->readProperty($adapter, 'timeout')); + $this->assertTrue($this->readProperty($adapter, 'keepAlive')); + $this->assertSame(15, $this->readProperty($adapter, 'timelimit')); + } + + public function test_creates_smtps_adapter_with_implicit_ssl_defaults(): void + { + $adapter = EmailAdapter::fromDsn('smtps://user:pass@mail.example.com'); + + $this->assertInstanceOf(SMTP::class, $adapter); + $this->assertSame(465, $this->readProperty($adapter, 'port')); + $this->assertSame('ssl', $this->readProperty($adapter, 'smtpSecure')); + } + + public function test_creates_resend_adapter_from_dsn(): void + { + $adapter = EmailAdapter::fromDsn('resend://re_test_key@default'); + + $this->assertInstanceOf(Resend::class, $adapter); + $this->assertSame('re_test_key', $this->readProperty($adapter, 'apiKey')); + } + + public function test_creates_sendgrid_adapter_from_dsn(): void + { + $adapter = EmailAdapter::fromDsn('sendgrid://sg_test_key@default'); + + $this->assertInstanceOf(Sendgrid::class, $adapter); + $this->assertSame('sg_test_key', $this->readProperty($adapter, 'apiKey')); + } + + public function test_creates_mailgun_adapter_from_dsn(): void + { + $adapter = EmailAdapter::fromDsn('mailgun://mg_test_key@example.com?eu=1'); + + $this->assertInstanceOf(Mailgun::class, $adapter); + $this->assertSame('mg_test_key', $this->readProperty($adapter, 'apiKey')); + $this->assertSame('example.com', $this->readProperty($adapter, 'domain')); + $this->assertTrue($this->readProperty($adapter, 'isEU')); + } + + public function test_rejects_unsupported_scheme(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported email DSN scheme "ses".'); + + EmailAdapter::fromDsn('ses://key@default'); + } + + public function test_rejects_invalid_dsn(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid email DSN.'); + + EmailAdapter::fromDsn('not a dsn'); + } + + public function test_rejects_malformed_smtp_dsn(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid email DSN.'); + + EmailAdapter::fromDsn('smtp://'); + } + + public function test_rejects_missing_resend_api_key(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Resend DSN must include an API key.'); + + EmailAdapter::fromDsn('resend://@default'); + } + + public function test_rejects_invalid_boolean_query_value(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "autotls" option. Expected boolean-like value.'); + + EmailAdapter::fromDsn('smtp://mail.example.com?autotls=maybe'); + } + + public function test_rejects_invalid_integer_query_value(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "timeout" option. Expected integer value.'); + + EmailAdapter::fromDsn('smtp://mail.example.com?timeout=fast'); + } + + public function test_rejects_invalid_secure_query_value(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid SMTP "secure" option. Expected "", "ssl", or "tls".'); + + EmailAdapter::fromDsn('smtp://mail.example.com?secure=starttls'); + } + + private function readProperty(object $object, string $property): mixed + { + $reflection = new \ReflectionClass($object); + $property = $reflection->getProperty($property); + + return $property->getValue($object); + } +} diff --git a/tests/Messaging/MessengerTest.php b/tests/Messaging/MessengerTest.php new file mode 100644 index 00000000..42f6fa66 --- /dev/null +++ b/tests/Messaging/MessengerTest.php @@ -0,0 +1,395 @@ +createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('First'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $firstAdapter->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'success', 'error' => '']], + ]); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('Second'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->expects($this->never())->method('send'); + + $messenger = new Messenger([$firstAdapter, $secondAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $result = $messenger->send($message); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_falls_back_to_second_adapter_when_first_throws(): void + { + $firstAdapter = $this->createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('First'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $firstAdapter->method('send')->willThrowException(new \Exception('Connection failed')); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('Second'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'success', 'error' => '']], + ]); + + $messenger = new Messenger([$firstAdapter, $secondAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $result = $messenger->send($message); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_tries_multiple_adapters_until_success(): void + { + $firstAdapter = $this->createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('First'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $firstAdapter->method('send')->willThrowException(new \Exception('Error 1')); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('Second'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->method('send')->willThrowException(new \Exception('Error 2')); + + $thirdAdapter = $this->createMock(Adapter::class); + $thirdAdapter->method('getName')->willReturn('Third'); + $thirdAdapter->method('getType')->willReturn('sms'); + $thirdAdapter->method('getMessageType')->willReturn(SMS::class); + $thirdAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $thirdAdapter->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'success', 'error' => '']], + ]); + + $messenger = new Messenger([$firstAdapter, $secondAdapter, $thirdAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $result = $messenger->send($message); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_throws_when_all_adapters_fail(): void + { + $firstAdapter = $this->createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('FirstAdapter'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $firstAdapter->method('send')->willThrowException(new \Exception('Connection timeout')); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('SecondAdapter'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->method('send')->willThrowException(new \Exception('API error')); + + $messenger = new Messenger([$firstAdapter, $secondAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('All 2 adapters failed'); + $this->expectExceptionMessage('FirstAdapter (adapter 1): Connection timeout'); + $this->expectExceptionMessage('SecondAdapter (adapter 2): API error'); + + $messenger->send($message); + } + + public function test_throws_when_single_adapter_fails(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('OnlyAdapter'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + $adapter->method('send')->willThrowException(new \Exception('Network error')); + + $messenger = new Messenger([$adapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('All 1 adapter failed'); + $this->expectExceptionMessage('OnlyAdapter (adapter 1): Network error'); + + $messenger->send($message); + } + + public function test_accepts_single_adapter_instance(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('OnlyAdapter'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + $adapter->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'success', 'error' => '']], + ]); + + $messenger = new Messenger($adapter); + + $result = $messenger->send(new SMS( + to: ['+1234567890'], + content: 'Test message' + )); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_rejects_empty_adapter_list(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('At least one adapter must be provided'); + + new Messenger([]); + } + + public function test_rejects_non_adapter_array_element(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('All elements must be instances of Adapter, but element 1 is string.'); + + new Messenger([ + $this->createMock(Adapter::class), + 'not-an-adapter', + ]); + } + + public function test_rejects_mixed_adapter_types(): void + { + $smsAdapter = $this->createMock(Adapter::class); + $smsAdapter->method('getName')->willReturn('SMS'); + $smsAdapter->method('getType')->willReturn('sms'); + $smsAdapter->method('getMessageType')->willReturn(SMS::class); + + $emailAdapter = $this->createMock(Adapter::class); + $emailAdapter->method('getName')->willReturn('Email'); + $emailAdapter->method('getType')->willReturn('email'); + $emailAdapter->method('getMessageType')->willReturn(Email::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('All adapters must be of the same type'); + + new Messenger([$smsAdapter, $emailAdapter]); + } + + public function test_rejects_mixed_message_types(): void + { + $smsAdapter1 = $this->createMock(Adapter::class); + $smsAdapter1->method('getName')->willReturn('SMS1'); + $smsAdapter1->method('getType')->willReturn('sms'); + $smsAdapter1->method('getMessageType')->willReturn(SMS::class); + + $smsAdapter2 = $this->createMock(Adapter::class); + $smsAdapter2->method('getName')->willReturn('SMS2'); + $smsAdapter2->method('getType')->willReturn('sms'); + $smsAdapter2->method('getMessageType')->willReturn(Email::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('All adapters must support the same message type'); + + new Messenger([$smsAdapter1, $smsAdapter2]); + } + + public function test_get_max_messages_per_request_returns_minimum(): void + { + $adapter1 = $this->createMock(Adapter::class); + $adapter1->method('getName')->willReturn('Adapter1'); + $adapter1->method('getType')->willReturn('sms'); + $adapter1->method('getMessageType')->willReturn(SMS::class); + $adapter1->method('getMaxMessagesPerRequest')->willReturn(500); + + $adapter2 = $this->createMock(Adapter::class); + $adapter2->method('getName')->willReturn('Adapter2'); + $adapter2->method('getType')->willReturn('sms'); + $adapter2->method('getMessageType')->willReturn(SMS::class); + $adapter2->method('getMaxMessagesPerRequest')->willReturn(100); + + $adapter3 = $this->createMock(Adapter::class); + $adapter3->method('getName')->willReturn('Adapter3'); + $adapter3->method('getType')->willReturn('sms'); + $adapter3->method('getMessageType')->willReturn(SMS::class); + $adapter3->method('getMaxMessagesPerRequest')->willReturn(1000); + + $messenger = new Messenger([$adapter1, $adapter2, $adapter3]); + + $this->assertEquals(100, $messenger->getMaxMessagesPerRequest()); + } + + public function test_get_type_returns_first_adapter_type(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('Test'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + + $messenger = new Messenger([$adapter]); + + $this->assertEquals('sms', $messenger->getType()); + } + + public function test_get_message_type_returns_first_adapter_message_type(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('Test'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + + $messenger = new Messenger([$adapter]); + + $this->assertEquals(SMS::class, $messenger->getMessageType()); + } + + public function test_rejects_invalid_message_type(): void + { + $adapter = $this->createMock(Adapter::class); + $adapter->method('getName')->willReturn('SMS'); + $adapter->method('getType')->willReturn('sms'); + $adapter->method('getMessageType')->willReturn(SMS::class); + $adapter->method('getMaxMessagesPerRequest')->willReturn(100); + + $messenger = new Messenger([$adapter]); + + $message = new Email( + to: ['test@example.com'], + subject: 'Test', + content: 'Test content', + fromName: 'Sender', + fromEmail: 'sender@example.com' + ); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid message type'); + + $messenger->send($message); + } + + public function test_works_with_email_adapters(): void + { + $adapter1 = $this->createMock(Adapter::class); + $adapter1->method('getName')->willReturn('Sendgrid'); + $adapter1->method('getType')->willReturn('email'); + $adapter1->method('getMessageType')->willReturn(Email::class); + $adapter1->method('getMaxMessagesPerRequest')->willReturn(100); + $adapter1->method('send')->willThrowException(new \Exception('API down')); + + $adapter2 = $this->createMock(Adapter::class); + $adapter2->method('getName')->willReturn('Mailgun'); + $adapter2->method('getType')->willReturn('email'); + $adapter2->method('getMessageType')->willReturn(Email::class); + $adapter2->method('getMaxMessagesPerRequest')->willReturn(100); + $adapter2->method('send')->willReturn([ + 'deliveredTo' => 1, + 'type' => 'email', + 'results' => [['recipient' => 'test@example.com', 'status' => 'success', 'error' => '']], + ]); + + $messenger = new Messenger([$adapter1, $adapter2]); + + $message = new Email( + to: ['test@example.com'], + subject: 'Test', + content: 'Test content', + fromName: 'Sender', + fromEmail: 'sender@example.com' + ); + + $result = $messenger->send($message); + + $this->assertEquals(1, $result['deliveredTo']); + $this->assertEquals('success', $result['results'][0]['status']); + } + + public function test_does_not_fallback_on_returned_failure_payload(): void + { + $firstAdapter = $this->createMock(Adapter::class); + $firstAdapter->method('getName')->willReturn('First'); + $firstAdapter->method('getType')->willReturn('sms'); + $firstAdapter->method('getMessageType')->willReturn(SMS::class); + $firstAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $firstAdapter->method('send')->willReturn([ + 'deliveredTo' => 0, + 'type' => 'sms', + 'results' => [['recipient' => '+1234567890', 'status' => 'failure', 'error' => 'Rate limited']], + ]); + + $secondAdapter = $this->createMock(Adapter::class); + $secondAdapter->method('getName')->willReturn('Second'); + $secondAdapter->method('getType')->willReturn('sms'); + $secondAdapter->method('getMessageType')->willReturn(SMS::class); + $secondAdapter->method('getMaxMessagesPerRequest')->willReturn(100); + $secondAdapter->expects($this->never())->method('send'); + + $messenger = new Messenger([$firstAdapter, $secondAdapter]); + + $message = new SMS( + to: ['+1234567890'], + content: 'Test message' + ); + + $result = $messenger->send($message); + + $this->assertEquals(0, $result['deliveredTo']); + $this->assertEquals('failure', $result['results'][0]['status']); + } +}