diff --git a/README.md b/README.md index b4b36732..f246ec77 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,21 @@ $messaging = new Resend('YOUR_API_KEY'); $messaging->send($message); ``` +You can also create email adapters from a DSN: + +```php +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/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..924a3e40 --- /dev/null +++ b/src/Utopia/Messaging/Messenger.php @@ -0,0 +1,203 @@ +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, an array element is not an adapter, 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.'); + } + + 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; + } + + /** + * 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( + '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) + ); + } + + /** + * 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 (\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..ff3717d6 --- /dev/null +++ b/tests/Messaging/MessengerTest.php @@ -0,0 +1,399 @@ +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]); + + // 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']); + } +}