From c1c2409911cd967bbb3522785bce241af4e00e48 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Thu, 2 Apr 2026 15:41:37 +0400 Subject: [PATCH 1/2] feat: add Messenger class for multiple adapter failover support This commit introduces the Messenger class, which enables automatic failover across multiple messaging adapters. If one adapter throws an exception, the next adapter in the sequence is tried until one succeeds or all fail. Features: - Accepts a single Adapter or an array of Adapters - Tries adapters sequentially on exception - Validates adapter compatibility (same type and message type) - Returns the first successful response - Throws aggregated exception with details if all adapters fail - Supports SMS, Email, Push, and any other adapter types Example usage: $messenger = new Messenger([ new Twilio('sid', 'token'), new Vonage('key', 'secret'), ]); $result = $messenger->send($message); Changes: - Add src/Utopia/Messaging/Messenger.php - Add tests/Messaging/MessengerTest.php with comprehensive test coverage - Update README.md with usage example Closes: feature request for multiple adapter support --- README.md | 27 +++ src/Utopia/Messaging/Messenger.php | 176 ++++++++++++++ tests/Messaging/MessengerTest.php | 364 +++++++++++++++++++++++++++++ 3 files changed, 567 insertions(+) create mode 100644 src/Utopia/Messaging/Messenger.php create mode 100644 tests/Messaging/MessengerTest.php diff --git a/README.md b/README.md index b4b36732..aac6126d 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,33 @@ $messaging = new FCM('YOUR_SERVICE_ACCOUNT_JSON'); $messaging->send($message); ``` +## Multiple Adapters (Failover) + +You can use multiple adapters with automatic failover. If one adapter throws an exception, the next one will be tried. + +```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/Messenger.php b/src/Utopia/Messaging/Messenger.php new file mode 100644 index 00000000..a6a5f2df --- /dev/null +++ b/src/Utopia/Messaging/Messenger.php @@ -0,0 +1,176 @@ +send($message); + * ``` + */ +class Messenger +{ + /** + * @var array + */ + private array $adapters; + + /** + * @param Adapter|array $adapters An adapter or array of adapters to try in sequence. + * At least one adapter must be provided. + * All adapters must support the same message type. + * + * @throws \InvalidArgumentException If no adapters are provided or adapters have mixed types. + */ + public function __construct(Adapter|array $adapters) + { + if ($adapters instanceof Adapter) { + $adapters = [$adapters]; + } + + if (empty($adapters)) { + throw new \InvalidArgumentException('At least one adapter must be provided.'); + } + + $this->validateAdapters($adapters); + + $this->adapters = $adapters; + } + + /** + * Send a message using the first available adapter. + * + * Tries each adapter in sequence. If an adapter throws an exception, + * it moves to the next adapter. Returns the result of the first + * successful adapter. + * + * @param Message $message The message to send. + * @return array{ + * deliveredTo: int, + * type: string, + * results: array> + * } + * + * @throws \Exception If all adapters fail or if the message type is invalid. + */ + public function send(Message $message): array + { + $errors = []; + $messageType = $this->adapters[0]->getMessageType(); + + if (! \is_a($message, $messageType)) { + throw new \Exception(sprintf( + 'Invalid message type. Expected "%s", got "%s".', + $messageType, + get_class($message) + )); + } + + foreach ($this->adapters as $index => $adapter) { + try { + return $adapter->send($message); + } catch (\Exception $e) { + $errors[] = sprintf( + '%s (adapter %d): %s', + $adapter->getName(), + $index + 1, + $e->getMessage() + ); + } + } + + throw new \Exception(sprintf( + "All %d adapters failed:\n%s", + count($this->adapters), + implode("\n", $errors) + )); + } + + /** + * Get the message type supported by this messenger. + * + * All adapters must support the same message type. + */ + public function getMessageType(): string + { + return $this->adapters[0]->getMessageType(); + } + + /** + * Get the adapter type (sms, email, push, etc.). + * + * All adapters must be of the same type. + */ + public function getType(): string + { + return $this->adapters[0]->getType(); + } + + /** + * Get the maximum number of messages that can be sent in a single request. + * + * Returns the minimum maxMessagesPerRequest of all adapters to ensure + * the messenger never accepts a message that any adapter cannot handle. + */ + public function getMaxMessagesPerRequest(): int + { + return array_reduce( + $this->adapters, + fn ($min, $adapter) => min($min, $adapter->getMaxMessagesPerRequest()), + PHP_INT_MAX + ); + } + + /** + * Validate that all adapters are compatible. + * + * @param array $adapters + * + * @throws \InvalidArgumentException If adapters are not compatible. + */ + private function validateAdapters(array $adapters): void + { + $firstAdapter = $adapters[0]; + $expectedType = $firstAdapter->getType(); + $expectedMessageType = $firstAdapter->getMessageType(); + + foreach ($adapters as $index => $adapter) { + if ($adapter->getType() !== $expectedType) { + throw new \InvalidArgumentException(sprintf( + 'All adapters must be of the same type. Expected "%s", but adapter %d (%s) has type "%s".', + $expectedType, + $index + 1, + $adapter->getName(), + $adapter->getType() + )); + } + + if ($adapter->getMessageType() !== $expectedMessageType) { + throw new \InvalidArgumentException(sprintf( + 'All adapters must support the same message type. Expected "%s", but adapter %d (%s) supports "%s".', + $expectedMessageType, + $index + 1, + $adapter->getName(), + $adapter->getMessageType() + )); + } + } + } +} diff --git a/tests/Messaging/MessengerTest.php b/tests/Messaging/MessengerTest.php new file mode 100644 index 00000000..b69679dd --- /dev/null +++ b/tests/Messaging/MessengerTest.php @@ -0,0 +1,364 @@ +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 adapters failed'); + $this->expectExceptionMessage('OnlyAdapter (adapter 1): Network error'); + + $messenger->send($message); + } + + 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_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]); + + // Create an Email message when Messenger expects SMS + $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 + { + // This tests that we ONLY fallback on exceptions, not on failure responses + $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); + // Returns a failure response (no exception thrown) + $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); + + // Should return the first adapter's failure response + $this->assertEquals(0, $result['deliveredTo']); + $this->assertEquals('failure', $result['results'][0]['status']); + } +} From 2e823a41e7230f6dbb1f5fa3aa7c7479dfc0c1d4 Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Thu, 2 Apr 2026 19:25:50 +0400 Subject: [PATCH 2/2] fix: harden Messenger validation and review feedback Validate Messenger adapter arrays at runtime so invalid elements fail with a clear InvalidArgumentException instead of a PHP error. This keeps the new Adapter|Adapter[] constructor ergonomic without weakening input validation. Also replace Messenger sprintf-based error construction with direct string concatenation, pluralize the single-adapter failure message correctly, and add test coverage for single-adapter construction and invalid array elements. --- src/Utopia/Messaging/Messenger.php | 91 +++++++++++++++++++----------- tests/Messaging/MessengerTest.php | 37 +++++++++++- 2 files changed, 95 insertions(+), 33 deletions(-) diff --git a/src/Utopia/Messaging/Messenger.php b/src/Utopia/Messaging/Messenger.php index a6a5f2df..924a3e40 100644 --- a/src/Utopia/Messaging/Messenger.php +++ b/src/Utopia/Messaging/Messenger.php @@ -37,7 +37,7 @@ class Messenger * At least one adapter must be provided. * All adapters must support the same message type. * - * @throws \InvalidArgumentException If no adapters are provided or adapters have mixed types. + * @throws \InvalidArgumentException If no adapters are provided, an array element is not an adapter, or adapters have mixed types. */ public function __construct(Adapter|array $adapters) { @@ -49,6 +49,18 @@ public function __construct(Adapter|array $adapters) throw new \InvalidArgumentException('At least one adapter must be provided.'); } + foreach ($adapters as $index => $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; @@ -76,31 +88,38 @@ public function send(Message $message): array $messageType = $this->adapters[0]->getMessageType(); if (! \is_a($message, $messageType)) { - throw new \Exception(sprintf( - 'Invalid message type. Expected "%s", got "%s".', - $messageType, - get_class($message) - )); + 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[] = sprintf( - '%s (adapter %d): %s', - $adapter->getName(), - $index + 1, - $e->getMessage() - ); + $errors[] = $adapter->getName() + .' (adapter ' + .($index + 1) + .'): ' + .$e->getMessage(); } } - throw new \Exception(sprintf( - "All %d adapters failed:\n%s", - count($this->adapters), - implode("\n", $errors) - )); + $adapterCount = \count($this->adapters); + $adapterLabel = $adapterCount === 1 ? 'adapter' : 'adapters'; + + throw new \Exception( + 'All ' + .$adapterCount + .' ' + .$adapterLabel + ." failed:\n" + .\implode("\n", $errors) + ); } /** @@ -151,25 +170,33 @@ private function validateAdapters(array $adapters): void $expectedType = $firstAdapter->getType(); $expectedMessageType = $firstAdapter->getMessageType(); - foreach ($adapters as $index => $adapter) { + foreach (\array_slice($adapters, 1, preserve_keys: true) as $index => $adapter) { if ($adapter->getType() !== $expectedType) { - throw new \InvalidArgumentException(sprintf( - 'All adapters must be of the same type. Expected "%s", but adapter %d (%s) has type "%s".', - $expectedType, - $index + 1, - $adapter->getName(), - $adapter->getType() - )); + 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(sprintf( - 'All adapters must support the same message type. Expected "%s", but adapter %d (%s) supports "%s".', - $expectedMessageType, - $index + 1, - $adapter->getName(), - $adapter->getMessageType() - )); + 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/MessengerTest.php b/tests/Messaging/MessengerTest.php index b69679dd..ff3717d6 100644 --- a/tests/Messaging/MessengerTest.php +++ b/tests/Messaging/MessengerTest.php @@ -164,12 +164,36 @@ public function test_throws_when_single_adapter_fails(): void ); $this->expectException(\Exception::class); - $this->expectExceptionMessage('All 1 adapters failed'); + $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); @@ -178,6 +202,17 @@ public function test_rejects_empty_adapter_list(): void 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);