From e80e1176a0bc471877cbd8aa08922b519fe5c272 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 26 Jul 2025 09:19:22 +0300 Subject: [PATCH] Added AudioAttachmentRequest & ContactAttachmentRequest & FileAttachmentRequest & LocationAttachmentRequest & ShareAttachmentRequest & StickerAttachmentRequest & VideoAttachmentRequest --- README.md | 66 +-- badge-coverage.svg | 6 +- composer.json | 2 + src/Api.php | 10 +- src/Enums/AttachmentType.php | 2 +- ...p => AbstractAttachmentRequestPayload.php} | 2 +- .../ContactAttachmentRequestPayload.php | 25 + ...nlineKeyboardAttachmentRequestPayload.php} | 2 +- .../LocationAttachmentRequestPayload.php | 21 + ....php => PhotoAttachmentRequestPayload.php} | 4 +- .../ShareAttachmentRequestPayload.php | 28 ++ .../StickerAttachmentRequestPayload.php | 19 + .../UploadedInfoAttachmentRequestPayload.php | 19 + .../Requests/AbstractAttachmentRequest.php | 4 +- .../Requests/AudioAttachmentRequest.php | 25 + .../Requests/ContactAttachmentRequest.php | 32 ++ .../Requests/FileAttachmentRequest.php | 25 + .../InlineKeyboardAttachmentRequest.php | 4 +- .../Requests/LocationAttachmentRequest.php | 26 + .../Requests/PhotoAttachmentRequest.php | 12 +- .../Requests/ShareAttachmentRequest.php | 43 ++ .../Requests/StickerAttachmentRequest.php | 25 + .../Requests/VideoAttachmentRequest.php | 25 + src/WebhookHandler.php | 26 +- tests/ApiTest.php | 453 +++++++++++++++++- tests/ClientTest.php | 83 +++- tests/ModelFactoryTest.php | 35 +- tests/Models/AbstractModelMappingTest.php | 140 +++++- .../ContactAttachmentRequestPayloadTest.php | 83 ++++ .../Payloads/InlineKeyboardPayloadTest.php | 12 +- .../LocationAttachmentRequestPayloadTest.php | 45 ++ .../Payloads/PhotoAttachmentPayloadTest.php | 14 +- .../ShareAttachmentRequestPayloadTest.php | 61 +++ .../StickerAttachmentRequestPayloadTest.php | 32 ++ .../Payloads/UploadedInfoPayloadTest.php | 32 ++ .../Requests/AudioAttachmentRequestTest.php | 39 ++ .../Requests/ContactAttachmentRequestTest.php | 63 +++ .../Requests/FileAttachmentRequestTest.php | 39 ++ .../InlineKeyboardAttachmentRequestTest.php | 6 +- .../LocationAttachmentRequestTest.php | 42 ++ .../Requests/PhotoAttachmentRequestTest.php | 14 +- .../Requests/ShareAttachmentRequestTest.php | 62 +++ .../Requests/StickerAttachmentRequestTest.php | 39 ++ .../Requests/VideoAttachmentRequestTest.php | 40 ++ tests/WebhookHandlerTest.php | 32 ++ 45 files changed, 1714 insertions(+), 105 deletions(-) rename src/Models/Attachments/Payloads/{AbstractAttachmentPayload.php => AbstractAttachmentRequestPayload.php} (67%) create mode 100644 src/Models/Attachments/Payloads/ContactAttachmentRequestPayload.php rename src/Models/Attachments/Payloads/{InlineKeyboardPayload.php => InlineKeyboardAttachmentRequestPayload.php} (75%) create mode 100644 src/Models/Attachments/Payloads/LocationAttachmentRequestPayload.php rename src/Models/Attachments/Payloads/{PhotoAttachmentPayload.php => PhotoAttachmentRequestPayload.php} (87%) create mode 100644 src/Models/Attachments/Payloads/ShareAttachmentRequestPayload.php create mode 100644 src/Models/Attachments/Payloads/StickerAttachmentRequestPayload.php create mode 100644 src/Models/Attachments/Payloads/UploadedInfoAttachmentRequestPayload.php create mode 100644 src/Models/Attachments/Requests/AudioAttachmentRequest.php create mode 100644 src/Models/Attachments/Requests/ContactAttachmentRequest.php create mode 100644 src/Models/Attachments/Requests/FileAttachmentRequest.php create mode 100644 src/Models/Attachments/Requests/LocationAttachmentRequest.php create mode 100644 src/Models/Attachments/Requests/ShareAttachmentRequest.php create mode 100644 src/Models/Attachments/Requests/StickerAttachmentRequest.php create mode 100644 src/Models/Attachments/Requests/VideoAttachmentRequest.php create mode 100644 tests/Models/Attachments/Payloads/ContactAttachmentRequestPayloadTest.php create mode 100644 tests/Models/Attachments/Payloads/LocationAttachmentRequestPayloadTest.php create mode 100644 tests/Models/Attachments/Payloads/ShareAttachmentRequestPayloadTest.php create mode 100644 tests/Models/Attachments/Payloads/StickerAttachmentRequestPayloadTest.php create mode 100644 tests/Models/Attachments/Payloads/UploadedInfoPayloadTest.php create mode 100644 tests/Models/Attachments/Requests/AudioAttachmentRequestTest.php create mode 100644 tests/Models/Attachments/Requests/ContactAttachmentRequestTest.php create mode 100644 tests/Models/Attachments/Requests/FileAttachmentRequestTest.php create mode 100644 tests/Models/Attachments/Requests/LocationAttachmentRequestTest.php create mode 100644 tests/Models/Attachments/Requests/ShareAttachmentRequestTest.php create mode 100644 tests/Models/Attachments/Requests/StickerAttachmentRequestTest.php create mode 100644 tests/Models/Attachments/Requests/VideoAttachmentRequestTest.php diff --git a/README.md b/README.md index 0219b2e..4a8419c 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) > [!CAUTION] -> На мой взгляд `Max Messenger` является ни чем иным как малварью, созданной для слежки за гражданами РФ. -> Настоятельно не рекомендую использовать на реальных устройствах, с настоящим номером телефона, и для личной переписки. +> На мой взгляд `Max Messenger` является ни чем иным как малварью, созданной для слежки за гражданами РФ. Настоятельно +> не рекомендую использовать его на реальных устройствах, с настоящим номером телефона, и для личной переписки. > [!IMPORTANT] > Библиотека в стадии активной разработки. @@ -25,46 +25,46 @@ #### Bots -- `[x] GET /me` (`getBotInfo`) — *Получение информации о боте.* -- `[ ] PATCH /me` (`editBotInfo`) — *Редактирование информации о боте.* +- [x] `GET /me` (`getBotInfo`) — *Получение информации о боте.* +- [ ] `PATCH /me` (`editBotInfo`) — *Редактирование информации о боте.* #### Chats -- `[ ] GET /chats` (`getChats`) — *Получение списка всех чатов бота.* -- `[ ] GET /chats/{chatLink}` (`getChatByLink`) — *Получение информации о чате по ссылке.* -- `[x] GET /chats/{chatId}` (`getChat`) — *Получение информации о чате по ID.* -- `[ ] PATCH /chats/{chatId}` (`editChat`) — *Редактирование информации о чате.* -- `[ ] DELETE /chats/{chatId}` (`deleteChat`) — *Удаление чата.* -- `[ ] POST /chats/{chatId}/actions` (`sendAction`) — *Отправка действия в чат (например, "печатает...").* -- `[ ] GET /chats/{chatId}/pin` (`getPinnedMessage`) — *Получение закрепленного сообщения.* -- `[ ] PUT /chats/{chatId}/pin` (`pinMessage`) — *Закрепление сообщения.* -- `[ ] DELETE /chats/{chatId}/pin` (`unpinMessage`) — *Открепление сообщения.* -- `[ ] GET /chats/{chatId}/members/me` (`getMembership`) — *Получение информации о членстве бота в чате.* -- `[ ] DELETE /chats/{chatId}/members/me` (`leaveChat`) — *Выход бота из чата.* -- `[ ] GET /chats/{chatId}/members/admins` (`getAdmins`) — *Получение администраторов чата.* -- `[ ] POST /chats/{chatId}/members/admins` (`postAdmins`) — *Назначение администраторов чата.* -- `[ ] DELETE /chats/{chatId}/members/admins/{userId}` (`deleteAdmins`) — *Снятие прав администратора.* -- `[ ] GET /chats/{chatId}/members` (`getMembers`) — *Получение участников чата.* -- `[ ] POST /chats/{chatId}/members` (`addMembers`) — *Добавление участников в чат.* -- `[ ] DELETE /chats/{chatId}/members` (`removeMember`) — *Удаление участника из чата.* +- [ ] `GET /chats` (`getChats`) — *Получение списка всех чатов бота.* +- [ ] `GET /chats/{chatLink}` (`getChatByLink`) — *Получение информации о чате по ссылке.* +- [x] `GET /chats/{chatId}` (`getChat`) — *Получение информации о чате по ID.* +- [ ] `PATCH /chats/{chatId}` (`editChat`) — *Редактирование информации о чате.* +- [ ] `DELETE /chats/{chatId}` (`deleteChat`) — *Удаление чата.* +- [ ] `POST /chats/{chatId}/actions` (`sendAction`) — *Отправка действия в чат (например, "печатает...").* +- [ ] `GET /chats/{chatId}/pin` (`getPinnedMessage`) — *Получение закрепленного сообщения.* +- [ ] `PUT /chats/{chatId}/pin` (`pinMessage`) — *Закрепление сообщения.* +- [ ] `DELETE /chats/{chatId}/pin` (`unpinMessage`) — *Открепление сообщения.* +- [ ] `GET /chats/{chatId}/members/me` (`getMembership`) — *Получение информации о членстве бота в чате.* +- [ ] `DELETE /chats/{chatId}/members/me` (`leaveChat`) — *Выход бота из чата.* +- [ ] `GET /chats/{chatId}/members/admins` (`getAdmins`) — *Получение администраторов чата.* +- [ ] `POST /chats/{chatId}/members/admins` (`postAdmins`) — *Назначение администраторов чата.* +- [ ] `DELETE /chats/{chatId}/members/admins/{userId}` (`deleteAdmins`) — *Снятие прав администратора.* +- [ ] `GET /chats/{chatId}/members` (`getMembers`) — *Получение участников чата.* +- [ ] `POST /chats/{chatId}/members` (`addMembers`) — *Добавление участников в чат.* +- [ ] `DELETE /chats/{chatId}/members` (`removeMember`) — *Удаление участника из чата.* #### Subscriptions -- `[x] GET /subscriptions` (`getSubscriptions`) — *Получение списка Webhook-подписок.* -- `[x] POST /subscriptions` (`subscribe`) — *Создание Webhook-подписки.* -- `[x] DELETE /subscriptions` (`unsubscribe`) — *Удаление Webhook-подписки.* -- `[x] GET /updates` (`getUpdates`) — *Получение обновлений через Long-Polling.* +- [x] `GET /subscriptions` (`getSubscriptions`) — *Получение списка Webhook-подписок.* +- [x] `POST /subscriptions` (`subscribe`) — *Создание Webhook-подписки.* +- [x] `DELETE /subscriptions` (`unsubscribe`) — *Удаление Webhook-подписки.* +- [x] `GET /updates` (`getUpdates`) — *Получение обновлений через Long-Polling.* #### Upload -- `[x] POST /uploads` (`getUploadUrl`) — *Получение URL для загрузки файла.* +- [x] `POST /uploads` (`getUploadUrl`) — *Получение URL для загрузки файла.* #### Messages -- `[ ] GET /messages` (`getMessages`) — *Получение списка сообщений из чата.* -- `[x] POST /messages` (`sendMessage`) — *Отправка сообщения.* -- `[ ] PUT /messages` (`editMessage`) — *Редактирование сообщения.* -- `[ ] DELETE /messages` (`deleteMessage`) — *Удаление сообщения.* -- `[ ] GET /messages/{messageId}` (`getMessageById`) — *Получение сообщения по ID.* -- `[ ] GET /videos/{videoToken}` (`getVideoAttachmentDetails`) — *Получение детальной информации о видео.* -- `[ ] POST /answers` (`answerOnCallback`) — *Ответ на нажатие callback-кнопки.* +- [ ] `GET /messages` (`getMessages`) — *Получение списка сообщений из чата.* +- [x] `POST /messages` (`sendMessage`) — *Отправка сообщения.* +- [ ] `PUT /messages` (`editMessage`) — *Редактирование сообщения.* +- [ ] `DELETE /messages` (`deleteMessage`) — *Удаление сообщения.* +- [ ] `GET /messages/{messageId}` (`getMessageById`) — *Получение сообщения по ID.* +- [ ] `GET /videos/{videoToken}` (`getVideoAttachmentDetails`) — *Получение детальной информации о видео.* +- [ ] `POST /answers` (`answerOnCallback`) — *Ответ на нажатие callback-кнопки.* diff --git a/badge-coverage.svg b/badge-coverage.svg index 55b0408..c695f17 100644 --- a/badge-coverage.svg +++ b/badge-coverage.svg @@ -9,13 +9,13 @@ - + coverage coverage - 94% - 94% + 100% + 100% \ No newline at end of file diff --git a/composer.json b/composer.json index 735898e..d723cca 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,8 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.77", "jaschilz/php-coverage-badger": "^2.0", + "mikey179/vfsstream": "^1.6", + "php-mock/php-mock-phpunit": "^2.13", "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^12.0", "roave/security-advisories": "dev-latest" diff --git a/src/Api.php b/src/Api.php index 3a315b8..06970c5 100644 --- a/src/Api.php +++ b/src/Api.php @@ -13,7 +13,10 @@ use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException; use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException; use BushlanovDev\MaxMessengerBot\Models\AbstractModel; use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AbstractAttachmentRequest; +use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AudioAttachmentRequest; +use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\FileAttachmentRequest; use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\PhotoAttachmentRequest; +use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\VideoAttachmentRequest; use BushlanovDev\MaxMessengerBot\Models\BotInfo; use BushlanovDev\MaxMessengerBot\Models\Chat; use BushlanovDev\MaxMessengerBot\Models\Message; @@ -71,12 +74,10 @@ class Api ) { if ($client === null) { if (!class_exists(\GuzzleHttp\Client::class) || !class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) { - // @codeCoverageIgnoreStart throw new LogicException( 'No client was provided and "guzzlehttp/guzzle" is not found. ' . 'Please run "composer require guzzlehttp/guzzle" or create and pass your own implementation of ClientApiInterface.' ); - // @codeCoverageIgnoreEnd } $guzzle = new \GuzzleHttp\Client(); @@ -429,7 +430,7 @@ class Api throw new InvalidArgumentException("File not found or not readable: $filePath"); } - $fileHandle = fopen($filePath, 'r'); + $fileHandle = @fopen($filePath, 'r'); if ($fileHandle === false) { throw new RuntimeException("Could not open file for reading: $filePath"); } @@ -450,6 +451,9 @@ class Api return match ($type) { UploadType::Image => PhotoAttachmentRequest::fromToken($uploadResult['token']), + UploadType::Video => new VideoAttachmentRequest($uploadResult['token']), + UploadType::Audio => new AudioAttachmentRequest($uploadResult['token']), + UploadType::File => new FileAttachmentRequest($uploadResult['token']), // @phpstan-ignore-line default => throw new LogicException( "Attachment creation for type '$type->value' is not yet implemented." ), diff --git a/src/Enums/AttachmentType.php b/src/Enums/AttachmentType.php index b6448cf..88691d2 100644 --- a/src/Enums/AttachmentType.php +++ b/src/Enums/AttachmentType.php @@ -11,7 +11,7 @@ enum AttachmentType: string case Audio = 'audio'; case File = 'file'; case Sticker = 'sticker'; - case Contract = 'contract'; + case Contact = 'contact'; case InlineKeyboard = 'inline_keyboard'; case Location = 'location'; case Share = 'share'; diff --git a/src/Models/Attachments/Payloads/AbstractAttachmentPayload.php b/src/Models/Attachments/Payloads/AbstractAttachmentRequestPayload.php similarity index 67% rename from src/Models/Attachments/Payloads/AbstractAttachmentPayload.php rename to src/Models/Attachments/Payloads/AbstractAttachmentRequestPayload.php index eb9c5b1..ac1777d 100644 --- a/src/Models/Attachments/Payloads/AbstractAttachmentPayload.php +++ b/src/Models/Attachments/Payloads/AbstractAttachmentRequestPayload.php @@ -6,6 +6,6 @@ namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads; use BushlanovDev\MaxMessengerBot\Models\AbstractModel; -abstract readonly class AbstractAttachmentPayload extends AbstractModel +abstract readonly class AbstractAttachmentRequestPayload extends AbstractModel { } diff --git a/src/Models/Attachments/Payloads/ContactAttachmentRequestPayload.php b/src/Models/Attachments/Payloads/ContactAttachmentRequestPayload.php new file mode 100644 index 0000000..bff2a85 --- /dev/null +++ b/src/Models/Attachments/Payloads/ContactAttachmentRequestPayload.php @@ -0,0 +1,25 @@ +url, $this->token, $this->photos])) !== 1) { throw new InvalidArgumentException( - 'Provide exactly one of "url", "token", or "photos" for PhotoAttachmentPayload.' + 'Provide exactly one of "url", "token", or "photos" for PhotoAttachmentRequestPayload.' ); } } diff --git a/src/Models/Attachments/Payloads/ShareAttachmentRequestPayload.php b/src/Models/Attachments/Payloads/ShareAttachmentRequestPayload.php new file mode 100644 index 0000000..bbea2f0 --- /dev/null +++ b/src/Models/Attachments/Payloads/ShareAttachmentRequestPayload.php @@ -0,0 +1,28 @@ +url, $this->token])) !== 1) { + throw new InvalidArgumentException( + 'Provide exactly one of "url" or "token" for ShareAttachmentRequestPayload.' + ); + } + } +} diff --git a/src/Models/Attachments/Payloads/StickerAttachmentRequestPayload.php b/src/Models/Attachments/Payloads/StickerAttachmentRequestPayload.php new file mode 100644 index 0000000..19c94b9 --- /dev/null +++ b/src/Models/Attachments/Payloads/StickerAttachmentRequestPayload.php @@ -0,0 +1,19 @@ +expectException(LogicException::class); + $this->expectExceptionMessageMatches('/"guzzlehttp\/guzzle" is not found/'); + + $classExistsMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'class_exists'); + + $classExistsMock->expects($this->once()) + ->with(\GuzzleHttp\Client::class) + ->willReturn(false); + + new Api('some-token'); + } + + #[Test] + public function handleUpdatesLoopCatchesGenericExceptionAndContinues(): void + { + $this->processUpdatesBatchCallCount = 0; + $handlers = [UpdateType::MessageCreated->value => fn() => null]; + + $apiMock = $this->getMockBuilder(Api::class) + ->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock]) + ->onlyMethods(['processUpdatesBatch']) + ->getMock(); + + $apiMock->expects($this->any()) + ->method('processUpdatesBatch') + ->willReturnCallback(function () { + switch ($this->processUpdatesBatchCallCount++) { + case 0: + return; + case 1: + throw new \BushlanovDev\MaxMessengerBot\Exceptions\SerializationException( + "Simulated JSON error" + ); + default: + throw new \Error("LoopBreak"); + } + }); + + $this->expectOutputRegex('/An error occurred: Simulated JSON error/'); + + try { + $apiMock->handleUpdates($handlers); + } catch (\Error $e) { + $this->assertSame('LoopBreak', $e->getMessage()); + $this->assertSame( + 3, + $this->processUpdatesBatchCallCount, + 'processUpdatesBatch should have been called 3 times, indicating the loop continued after the exception.', + ); + } + } + + #[Test] + public function uploadAttachmentThrowsRuntimeExceptionWhenPathIsADirectory(): void + { + $root = vfsStream::setup('root'); + $directory = vfsStream::newDirectory('my_dir')->at($root); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/Could not open file for reading/'); + + $this->api->uploadAttachment(UploadType::File, $directory->url()); + } + + #[Test] + public function uploadAttachmentThrowsSerializationExceptionForInvalidUploadResponse(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'test_upload_'); + file_put_contents($filePath, 'content'); + + $uploadType = UploadType::Image; + $uploadUrl = 'https://upload.server/path'; + + $getUploadUrlResponse = ['url' => $uploadUrl]; + $expectedEndpoint = new UploadEndpoint($uploadUrl); + + $invalidUploadResponse = ['status' => 'error', 'message' => 'Upload failed']; + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('POST', '/uploads', ['type' => $uploadType->value]) + ->willReturn($getUploadUrlResponse); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createUploadEndpoint') + ->with($getUploadUrlResponse) + ->willReturn($expectedEndpoint); + + $this->clientMock + ->expects($this->once()) + ->method('upload') + ->with($uploadUrl, $this->isResource(), basename($filePath)) + ->willReturn($invalidUploadResponse); + + $this->expectException(SerializationException::class); + $this->expectExceptionMessage('Could not find "token" in upload server response.'); + + try { + $this->api->uploadAttachment($uploadType, $filePath); + } finally { + unlink($filePath); + } + } + + #[Test] + public function uploadAttachmentSuccessfullyUploadsVideoAndReturnsAttachment(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'test_video_'); + file_put_contents($filePath, 'fake-video-content'); + + $uploadType = UploadType::Video; + $uploadUrl = 'https://upload.server/video_path'; + $uploadToken = 'VIDEO_TOKEN_XYZ'; + + $getUploadUrlResponse = ['url' => $uploadUrl]; + $uploadResponse = ['token' => $uploadToken]; + $expectedEndpoint = new UploadEndpoint($uploadUrl); + $expectedAttachment = new VideoAttachmentRequest($uploadToken); + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('POST', '/uploads', ['type' => $uploadType->value]) + ->willReturn($getUploadUrlResponse); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createUploadEndpoint') + ->with($getUploadUrlResponse) + ->willReturn($expectedEndpoint); + + $this->clientMock + ->expects($this->once()) + ->method('upload') + ->with($uploadUrl, $this->isResource(), basename($filePath)) + ->willReturn($uploadResponse); + + $result = $this->api->uploadAttachment($uploadType, $filePath); + + $this->assertEquals($expectedAttachment, $result); + + unlink($filePath); + } + + #[Test] + public function uploadAttachmentSuccessfullyUploadsAudioAndReturnsAttachment(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'test_audio_'); + file_put_contents($filePath, 'fake-audio-content'); + + $uploadType = UploadType::Audio; + $uploadUrl = 'https://upload.server/audio_path'; + $uploadToken = 'AUDIO_TOKEN_ABC'; + + $getUploadUrlResponse = ['url' => $uploadUrl]; + $uploadResponse = ['token' => $uploadToken]; + $expectedEndpoint = new UploadEndpoint($uploadUrl); + $expectedAttachment = new AudioAttachmentRequest($uploadToken); + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('POST', '/uploads', ['type' => $uploadType->value]) + ->willReturn($getUploadUrlResponse); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createUploadEndpoint') + ->with($getUploadUrlResponse) + ->willReturn($expectedEndpoint); + + $this->clientMock + ->expects($this->once()) + ->method('upload') + ->with($uploadUrl, $this->isResource(), basename($filePath)) + ->willReturn($uploadResponse); + + $result = $this->api->uploadAttachment($uploadType, $filePath); + + $this->assertEquals($expectedAttachment, $result); + + unlink($filePath); + } + + #[Test] + public function uploadAttachmentSuccessfullyUploadsFileAndReturnsAttachment(): void + { + $filePath = tempnam(sys_get_temp_dir(), 'test_file_'); + file_put_contents($filePath, 'fake-file-content'); + + $uploadType = UploadType::File; + $uploadUrl = 'https://upload.server/file_path'; + $uploadToken = 'FILE_TOKEN_QWERTY'; + + $getUploadUrlResponse = ['url' => $uploadUrl]; + $uploadResponse = ['token' => $uploadToken]; + $expectedEndpoint = new UploadEndpoint($uploadUrl); + $expectedAttachment = new FileAttachmentRequest($uploadToken); + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('POST', '/uploads', ['type' => $uploadType->value]) + ->willReturn($getUploadUrlResponse); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createUploadEndpoint') + ->with($getUploadUrlResponse) + ->willReturn($expectedEndpoint); + + $this->clientMock + ->expects($this->once()) + ->method('upload') + ->with($uploadUrl, $this->isResource(), basename($filePath)) + ->willReturn($uploadResponse); + + $result = $this->api->uploadAttachment($uploadType, $filePath); + + $this->assertEquals($expectedAttachment, $result); + + unlink($filePath); + } + + #[Test] + public function sendMessageWithStickerAttachmentBuildsCorrectRequest(): void + { + $chatId = 12345; + $stickerCode = 'sticker_id_ok'; + $stickerRequest = new StickerAttachmentRequest($stickerCode); + + $expectedBody = [ + 'attachments' => [ + [ + 'type' => 'sticker', + 'payload' => [ + 'code' => $stickerCode, + ], + ], + ], + 'notify' => true, + ]; + $expectedQuery = ['chat_id' => $chatId, 'disable_link_preview' => false]; + + $apiResponse = [ + 'message' => [ + 'timestamp' => time(), + 'body' => ['mid' => 'mid.sticker.1', 'seq' => 10], + 'recipient' => ['chat_type' => 'dialog', 'user_id' => $chatId], + ] + ]; + $expectedMessageObject = Message::fromArray($apiResponse['message']); + + $this->clientMock->expects($this->once()) + ->method('request') + ->with('POST', '/messages', $expectedQuery, $expectedBody) + ->willReturn($apiResponse); + + $this->modelFactoryMock->expects($this->once()) + ->method('createMessage') + ->with($apiResponse['message']) + ->willReturn($expectedMessageObject); + + $result = $this->api->sendMessage(chatId: $chatId, attachments: [$stickerRequest]); + + $this->assertSame($expectedMessageObject, $result); + } + + #[Test] + public function sendMessageWithContactAttachmentBuildsCorrectRequest(): void + { + $chatId = 12345; + $contactRequest = new ContactAttachmentRequest(name: 'Service Desk', vcfPhone: '555-1234'); + + $expectedBody = [ + 'attachments' => [ + [ + 'type' => 'contact', + 'payload' => [ + 'name' => 'Service Desk', + 'contact_id' => null, + 'vcf_info' => null, + 'vcf_phone' => '555-1234', + ], + ], + ], + 'notify' => true, + ]; + $expectedQuery = ['chat_id' => $chatId, 'disable_link_preview' => false]; + + $apiResponse = [ + 'message' => [ + 'timestamp' => time(), + 'body' => ['mid' => 'mid.contact.1', 'seq' => 11], + 'recipient' => ['chat_type' => 'dialog', 'user_id' => $chatId], + ] + ]; + $expectedMessageObject = Message::fromArray($apiResponse['message']); + + $this->clientMock->expects($this->once()) + ->method('request') + ->with('POST', '/messages', $expectedQuery, $expectedBody) + ->willReturn($apiResponse); + + $this->modelFactoryMock->expects($this->once()) + ->method('createMessage') + ->with($apiResponse['message']) + ->willReturn($expectedMessageObject); + + $result = $this->api->sendMessage(chatId: $chatId, attachments: [$contactRequest]); + + $this->assertSame($expectedMessageObject, $result); + } + + #[Test] + public function sendMessageWithLocationAttachmentBuildsCorrectRequest(): void + { + $chatId = 12345; + $latitude = 59.9343; + $longitude = 30.3351; + $locationRequest = new LocationAttachmentRequest($latitude, $longitude); + + $expectedBody = [ + 'attachments' => [ + [ + 'type' => 'location', + 'payload' => [ + 'latitude' => $latitude, + 'longitude' => $longitude, + ], + ], + ], + 'notify' => true, + ]; + $expectedQuery = ['chat_id' => $chatId, 'disable_link_preview' => false]; + + $apiResponse = [ + 'message' => [ + 'timestamp' => time(), + 'body' => ['mid' => 'mid.location.1', 'seq' => 12], + 'recipient' => ['chat_type' => 'dialog', 'user_id' => $chatId], + ] + ]; + $expectedMessageObject = Message::fromArray($apiResponse['message']); + + $this->clientMock->expects($this->once()) + ->method('request') + ->with('POST', '/messages', $expectedQuery, $expectedBody) + ->willReturn($apiResponse); + + $this->modelFactoryMock->expects($this->once()) + ->method('createMessage') + ->with($apiResponse['message']) + ->willReturn($expectedMessageObject); + + $result = $this->api->sendMessage(chatId: $chatId, attachments: [$locationRequest]); + + $this->assertSame($expectedMessageObject, $result); + } + + #[Test] + public function sendMessageWithShareAttachmentBuildsCorrectRequest(): void + { + $chatId = 12345; + $url = 'https://dev.max.ru'; + $shareRequest = ShareAttachmentRequest::fromUrl($url); + + $expectedBody = [ + 'attachments' => [ + [ + 'type' => 'share', + 'payload' => [ + 'url' => $url, + 'token' => null, + ], + ], + ], + 'notify' => true, + ]; + $expectedQuery = ['chat_id' => $chatId, 'disable_link_preview' => false]; + + $apiResponse = [ + 'message' => [ + 'timestamp' => time(), + 'body' => ['mid' => 'mid.share.1', 'seq' => 13], + 'recipient' => ['chat_type' => 'dialog', 'user_id' => $chatId], + ] + ]; + $expectedMessageObject = Message::fromArray($apiResponse['message']); + + $this->clientMock->expects($this->once()) + ->method('request') + ->with('POST', '/messages', $expectedQuery, $expectedBody) + ->willReturn($apiResponse); + + $this->modelFactoryMock->expects($this->once()) + ->method('createMessage') + ->with($apiResponse['message']) + ->willReturn($expectedMessageObject); + + $result = $this->api->sendMessage(chatId: $chatId, attachments: [$shareRequest]); + + $this->assertSame($expectedMessageObject, $result); + } } diff --git a/tests/ClientTest.php b/tests/ClientTest.php index cd74574..0506f6f 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -280,13 +280,18 @@ final class ClientTest extends TestCase $this->requestMock ->expects($this->once()) ->method('withBody') - ->with($this->callback(function (StreamInterface $stream) use ($fileContents, $fileName) { - $stream->rewind(); - $body = $stream->getContents(); - $this->assertStringContainsString('Content-Disposition: form-data; name="data"; filename="' . $fileName . '"', $body); - $this->assertStringContainsString($fileContents, $body); - return true; - })) + ->with( + $this->callback(function (StreamInterface $stream) use ($fileContents, $fileName) { + $stream->rewind(); + $body = $stream->getContents(); + $this->assertStringContainsString( + 'Content-Disposition: form-data; name="data"; filename="' . $fileName . '"', + $body, + ); + $this->assertStringContainsString($fileContents, $body); + return true; + }) + ) ->willReturn($this->requestMock); $this->requestMock @@ -307,4 +312,68 @@ final class ClientTest extends TestCase $result = $this->client->upload($uploadUrl, $fileContents, $fileName); $this->assertSame($responsePayload, $result); } + + #[Test] + public function uploadMethodHandlesStreamResourceCorrectly(): void + { + $uploadUrl = 'https://upload.server/path'; + $fileContents = 'data from a stream resource'; + $fileName = 'resource.txt'; + $responsePayload = ['token' => 'token_from_stream_upload']; + + $tmpFileHandle = tmpfile(); + fwrite($tmpFileHandle, $fileContents); + rewind($tmpFileHandle); + + $this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock); + $this->requestMock->method('withHeader')->willReturn($this->requestMock); + $this->requestMock->method('withBody')->willReturn($this->requestMock); + $this->httpClientMock->method('sendRequest')->willReturn($this->responseMock); + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->streamMock->method('__toString')->willReturn(json_encode($responsePayload)); + + $result = $this->client->upload($uploadUrl, $tmpFileHandle, $fileName); + + $this->assertSame($responsePayload, $result); + fclose($tmpFileHandle); + } + + #[Test] + public function uploadThrowsNetworkExceptionOnClientError(): void + { + $this->expectException(NetworkException::class); + + $this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock); + $this->requestMock->method('withHeader')->willReturn($this->requestMock); + $this->requestMock->method('withBody')->willReturn($this->requestMock); + + $psrException = new class extends \Exception implements ClientExceptionInterface { + }; + $this->httpClientMock + ->method('sendRequest') + ->with($this->requestMock) + ->willThrowException($psrException); + + $this->client->upload('http://some.url', 'content', 'file.txt'); + } + + #[Test] + public function uploadThrowsSerializationExceptionOnInvalidJsonResponse(): void + { + $this->expectException(SerializationException::class); + $this->expectExceptionMessage('Failed to decode upload server response JSON.'); + + $this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock); + $this->requestMock->method('withHeader')->willReturn($this->requestMock); + $this->requestMock->method('withBody')->willReturn($this->requestMock); + + $this->httpClientMock + ->method('sendRequest') + ->with($this->requestMock) + ->willReturn($this->responseMock); + + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->streamMock->method('__toString')->willReturn('{not-a-valid-json'); + $this->client->upload('http://some.url', 'content', 'file.txt'); + } } diff --git a/tests/ModelFactoryTest.php b/tests/ModelFactoryTest.php index c2dcca7..15400af 100644 --- a/tests/ModelFactoryTest.php +++ b/tests/ModelFactoryTest.php @@ -19,6 +19,8 @@ use BushlanovDev\MaxMessengerBot\Models\Sender; use BushlanovDev\MaxMessengerBot\Models\Subscription; use BushlanovDev\MaxMessengerBot\Models\UpdateList; use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate; +use BushlanovDev\MaxMessengerBot\Models\Updates\ChatTitleChangedUpdate; +use BushlanovDev\MaxMessengerBot\Models\Updates\MessageChatCreatedUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate; use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint; use BushlanovDev\MaxMessengerBot\Models\User; @@ -44,6 +46,8 @@ use PHPUnit\Framework\TestCase; #[UsesClass(UploadEndpoint::class)] #[UsesClass(Chat::class)] #[UsesClass(Image::class)] +#[UsesClass(ChatTitleChangedUpdate::class)] +#[UsesClass(MessageChatCreatedUpdate::class)] final class ModelFactoryTest extends TestCase { private ModelFactory $factory; @@ -268,6 +272,33 @@ final class ModelFactoryTest extends TestCase 'payload' => 'start_payload', 'user_locale' => 'ru-RU', ], + [ + 'update_type' => 'chat_title_changed', + 'timestamp' => 1680000000, + 'chat_id' => 12345, + 'user' => [ + 'user_id' => 54321, + 'first_name' => 'John', + 'is_bot' => false, + 'last_activity_time' => 1679999999, + ], + 'title' => 'New Awesome Chat Title', + ], + [ + 'update_type' => 'message_chat_created', + 'timestamp' => 1683000001, + 'chat' => [ + 'chat_id' => 54321, + 'type' => 'chat', + 'status' => 'active', + 'last_event_time' => 1683000001, + 'participants_count' => 1, + 'is_public' => false, + 'title' => 'Another Discussion', + ], + 'message_id' => 'mid.another.message', + 'start_payload' => null, + ], ], 'marker' => 12345, ]; @@ -276,9 +307,11 @@ final class ModelFactoryTest extends TestCase $this->assertInstanceOf(UpdateList::class, $updateList); $this->assertSame(12345, $updateList->marker); - $this->assertCount(2, $updateList->updates); + $this->assertCount(4, $updateList->updates); $this->assertInstanceOf(MessageCreatedUpdate::class, $updateList->updates[0]); $this->assertInstanceOf(BotStartedUpdate::class, $updateList->updates[1]); $this->assertSame('start_payload', $updateList->updates[1]->payload); + $this->assertInstanceOf(ChatTitleChangedUpdate::class, $updateList->updates[2]); + $this->assertInstanceOf(MessageChatCreatedUpdate::class, $updateList->updates[3]); } } diff --git a/tests/Models/AbstractModelMappingTest.php b/tests/Models/AbstractModelMappingTest.php index 4684944..dc2ac1c 100644 --- a/tests/Models/AbstractModelMappingTest.php +++ b/tests/Models/AbstractModelMappingTest.php @@ -10,6 +10,7 @@ use BushlanovDev\MaxMessengerBot\Models\AbstractModel; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use stdClass; #[CoversClass(AbstractModel::class)] #[CoversClass(ArrayOf::class)] @@ -76,6 +77,139 @@ final class AbstractModelMappingTest extends TestCase $this->assertNull($result->childModels); } + + #[Test] + public function itCorrectlyCastsInteger(): void + { + $result = DummyModelForMapping::fromArray(['untyped_int' => 123]); + + $this->assertIsInt($result->untypedInt); + $this->assertSame(123, $result->untypedInt); + } + + #[Test] + public function itCorrectlyCastsFloat(): void + { + $result = DummyModelForMapping::fromArray(['untyped_float' => 1.23]); + + $this->assertIsFloat($result->untypedFloat); + $this->assertSame(1.23, $result->untypedFloat); + } + + #[Test] + public function itCorrectlyCastsBoolean(): void + { + $result = DummyModelForMapping::fromArray(['untyped_bool' => true]); + + $this->assertIsBool($result->untypedBool); + $this->assertTrue($result->untypedBool); + } + + #[Test] + public function itReturnsValueAsIsForUnmanagedObjectType(): void + { + $externalObject = new stdClass(); + $externalObject->data = 'some value'; + + $rawData = [ + 'name' => 'Test With External Object', + 'external_object' => $externalObject, + ]; + + $result = ModelWithExternalObject::fromArray($rawData); + + $this->assertInstanceOf(ModelWithExternalObject::class, $result); + $this->assertSame($externalObject, $result->externalObject); + $this->assertSame('some value', $result->externalObject->data); + } + + #[Test] + public function castArrayHandlesNonArrayValueForArrayProperty(): void + { + $rawData = [ + 'name' => 'Test with scalar instead of array', + 'tags' => 'single_tag_value', + ]; + + $result = DummyModelForArrayCast::fromArray($rawData); + + $this->assertInstanceOf(DummyModelForArrayCast::class, $result); + $this->assertIsArray($result->tags); + $this->assertSame(['single_tag_value'], $result->tags); + } + + #[Test] + public function castArrayReturnsArrayAsIsForUnmanagedObjectTypesInArrayOf(): void + { + $items = [ + (object)['id' => 1, 'name' => 'Item A'], + (object)['id' => 2, 'name' => 'Item B'], + ]; + $rawData = [ + 'name' => 'Test with unmanaged objects', + 'items' => $items, + ]; + + $result = ModelWithUnmanagedArray::fromArray($rawData); + + $this->assertInstanceOf(ModelWithUnmanagedArray::class, $result); + $this->assertIsArray($result->items); + $this->assertSame($items, $result->items); + $this->assertSame('Item A', $result->items[0]->name); + } + + #[Test] + public function toArraySkipsUninitializedProperties(): void + { + $reflection = new \ReflectionClass(DummyModelForUninitializedProperty::class); + $instance = $reflection->newInstanceWithoutConstructor(); + + $initializedProp = $reflection->getProperty('initializedProp'); + $initializedProp->setValue($instance, 'I have a value'); + + $resultArray = $instance->toArray(); + + $expectedArray = [ + 'initialized_prop' => 'I have a value', + ]; + + $this->assertEquals($expectedArray, $resultArray); + $this->assertArrayNotHasKey('uninitialized_prop', $resultArray); + } +} + +final readonly class DummyModelForUninitializedProperty extends AbstractModel +{ + public string $initializedProp; + public int $uninitializedProp; +} + +final readonly class DummyModelForArrayCast extends AbstractModel +{ + public function __construct( + public ?string $name, + public ?array $tags, + ) { + } +} + +final readonly class ModelWithUnmanagedArray extends AbstractModel +{ + public function __construct( + public ?string $name, + #[ArrayOf(stdClass::class)] + public ?array $items, + ) { + } +} + +final readonly class ModelWithExternalObject extends AbstractModel +{ + public function __construct( + public ?string $name, + public ?stdClass $externalObject, + ) { + } } final readonly class DummyModelForMapping extends AbstractModel @@ -87,8 +221,10 @@ final readonly class DummyModelForMapping extends AbstractModel #[ArrayOf(DummyChildModel::class)] public ?array $childModels, public ?array $untypedArray, - ) - { + public ?int $untypedInt, + public ?float $untypedFloat, + public ?bool $untypedBool, + ) { } } diff --git a/tests/Models/Attachments/Payloads/ContactAttachmentRequestPayloadTest.php b/tests/Models/Attachments/Payloads/ContactAttachmentRequestPayloadTest.php new file mode 100644 index 0000000..4be1e00 --- /dev/null +++ b/tests/Models/Attachments/Payloads/ContactAttachmentRequestPayloadTest.php @@ -0,0 +1,83 @@ +assertInstanceOf(ContactAttachmentRequestPayload::class, $payload); + $this->assertSame($name, $payload->name); + $this->assertSame($contactId, $payload->contactId); + $this->assertSame($vcfInfo, $payload->vcfInfo); + $this->assertSame($vcfPhone, $payload->vcfPhone); + + $expectedArray = [ + 'name' => $name, + 'contact_id' => $contactId, + 'vcf_info' => $vcfInfo, + 'vcf_phone' => $vcfPhone, + ]; + $this->assertEquals($expectedArray, $payload->toArray()); + } + + #[Test] + public function itConstructsWithNullablePropertiesAndSerializesCorrectly(): void + { + $name = 'Jane Doe'; + $vcfPhone = 'TEL:+79876543210'; + + $payload = new ContactAttachmentRequestPayload( + name: $name, + vcfPhone: $vcfPhone, + ); + + $this->assertSame($name, $payload->name); + $this->assertNull($payload->contactId); + $this->assertNull($payload->vcfInfo); + $this->assertSame($vcfPhone, $payload->vcfPhone); + + $expectedArray = [ + 'name' => $name, + 'contact_id' => null, + 'vcf_info' => null, + 'vcf_phone' => $vcfPhone, + ]; + $this->assertEquals($expectedArray, $payload->toArray()); + } + + #[Test] + public function itConstructsWithAllNullsAndSerializesCorrectly(): void + { + $payload = new ContactAttachmentRequestPayload(); + + $this->assertNull($payload->name); + $this->assertNull($payload->contactId); + $this->assertNull($payload->vcfInfo); + $this->assertNull($payload->vcfPhone); + + $expectedArray = [ + 'name' => null, + 'contact_id' => null, + 'vcf_info' => null, + 'vcf_phone' => null, + ]; + $this->assertEquals($expectedArray, $payload->toArray()); + } +} diff --git a/tests/Models/Attachments/Payloads/InlineKeyboardPayloadTest.php b/tests/Models/Attachments/Payloads/InlineKeyboardPayloadTest.php index 0f1b764..5a57113 100644 --- a/tests/Models/Attachments/Payloads/InlineKeyboardPayloadTest.php +++ b/tests/Models/Attachments/Payloads/InlineKeyboardPayloadTest.php @@ -8,13 +8,13 @@ use BushlanovDev\MaxMessengerBot\Enums\ButtonType; use BushlanovDev\MaxMessengerBot\Enums\Intent; use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\CallbackButton; use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\LinkButton; -use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\InlineKeyboardPayload; +use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\InlineKeyboardAttachmentRequestPayload; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -#[CoversClass(InlineKeyboardPayload::class)] +#[CoversClass(InlineKeyboardAttachmentRequestPayload::class)] #[UsesClass(CallbackButton::class)] #[UsesClass(LinkButton::class)] final class InlineKeyboardPayloadTest extends TestCase @@ -25,9 +25,9 @@ final class InlineKeyboardPayloadTest extends TestCase $buttons = [ [new CallbackButton('Test', 'payload')], ]; - $payload = new InlineKeyboardPayload($buttons); + $payload = new InlineKeyboardAttachmentRequestPayload($buttons); - $this->assertInstanceOf(InlineKeyboardPayload::class, $payload); + $this->assertInstanceOf(InlineKeyboardAttachmentRequestPayload::class, $payload); $this->assertSame($buttons, $payload->buttons); } @@ -41,7 +41,7 @@ final class InlineKeyboardPayloadTest extends TestCase new LinkButton('Help', 'https://example.com/help'), ], ]; - $payload = new InlineKeyboardPayload($buttons); + $payload = new InlineKeyboardAttachmentRequestPayload($buttons); $resultArray = $payload->toArray(); @@ -77,7 +77,7 @@ final class InlineKeyboardPayloadTest extends TestCase #[Test] public function toArrayHandlesEmptyButtonsArray(): void { - $payload = new InlineKeyboardPayload([]); + $payload = new InlineKeyboardAttachmentRequestPayload([]); $resultArray = $payload->toArray(); $expectedArray = [ diff --git a/tests/Models/Attachments/Payloads/LocationAttachmentRequestPayloadTest.php b/tests/Models/Attachments/Payloads/LocationAttachmentRequestPayloadTest.php new file mode 100644 index 0000000..0af9d89 --- /dev/null +++ b/tests/Models/Attachments/Payloads/LocationAttachmentRequestPayloadTest.php @@ -0,0 +1,45 @@ +assertInstanceOf(LocationAttachmentRequestPayload::class, $payload); + $this->assertSame($latitude, $payload->latitude); + $this->assertSame($longitude, $payload->longitude); + + $expectedArray = [ + 'latitude' => $latitude, + 'longitude' => $longitude, + ]; + $this->assertEquals($expectedArray, $payload->toArray()); + } + + #[Test] + public function itHandlesNegativeCoordinates(): void + { + $latitude = -34.6037; + $longitude = -58.3816; + + $payload = new LocationAttachmentRequestPayload($latitude, $longitude); + + $this->assertSame($latitude, $payload->latitude); + $this->assertSame($longitude, $payload->longitude); + } +} diff --git a/tests/Models/Attachments/Payloads/PhotoAttachmentPayloadTest.php b/tests/Models/Attachments/Payloads/PhotoAttachmentPayloadTest.php index cd24af6..b374086 100644 --- a/tests/Models/Attachments/Payloads/PhotoAttachmentPayloadTest.php +++ b/tests/Models/Attachments/Payloads/PhotoAttachmentPayloadTest.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace BushlanovDev\MaxMessengerBot\Tests\Models\Attachments\Payloads; use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf; -use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentPayload; +use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentRequestPayload; use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoToken; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; @@ -14,7 +14,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; -#[CoversClass(PhotoAttachmentPayload::class)] +#[CoversClass(PhotoAttachmentRequestPayload::class)] #[UsesClass(PhotoToken::class)] #[UsesClass(ArrayOf::class)] final class PhotoAttachmentPayloadTest extends TestCase @@ -22,7 +22,7 @@ final class PhotoAttachmentPayloadTest extends TestCase #[Test] public function canBeCreatedWithUrlOnly(): void { - $payload = new PhotoAttachmentPayload(url: 'https://example.com/photo.jpg'); + $payload = new PhotoAttachmentRequestPayload(url: 'https://example.com/photo.jpg'); $this->assertSame('https://example.com/photo.jpg', $payload->url); $this->assertNull($payload->token); @@ -39,7 +39,7 @@ final class PhotoAttachmentPayloadTest extends TestCase #[Test] public function canBeCreatedWithTokenOnly(): void { - $payload = new PhotoAttachmentPayload(token: 'uploaded_token_abc'); + $payload = new PhotoAttachmentRequestPayload(token: 'uploaded_token_abc'); $this->assertSame('uploaded_token_abc', $payload->token); $this->assertNull($payload->url); @@ -60,7 +60,7 @@ final class PhotoAttachmentPayloadTest extends TestCase new PhotoToken('token_1'), new PhotoToken('token_2'), ]; - $payload = new PhotoAttachmentPayload(photos: $photos); + $payload = new PhotoAttachmentRequestPayload(photos: $photos); $this->assertSame($photos, $payload->photos); $this->assertNull($payload->url); @@ -101,8 +101,8 @@ final class PhotoAttachmentPayloadTest extends TestCase ?array $photos ): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Provide exactly one of "url", "token", or "photos" for PhotoAttachmentPayload.'); + $this->expectExceptionMessage('Provide exactly one of "url", "token", or "photos" for PhotoAttachmentRequestPayload.'); - new PhotoAttachmentPayload($url, $token, $photos); + new PhotoAttachmentRequestPayload($url, $token, $photos); } } diff --git a/tests/Models/Attachments/Payloads/ShareAttachmentRequestPayloadTest.php b/tests/Models/Attachments/Payloads/ShareAttachmentRequestPayloadTest.php new file mode 100644 index 0000000..d9d3ed3 --- /dev/null +++ b/tests/Models/Attachments/Payloads/ShareAttachmentRequestPayloadTest.php @@ -0,0 +1,61 @@ +assertSame('https://example.com', $payload->url); + $this->assertNull($payload->token); + + $expectedArray = ['url' => 'https://example.com', 'token' => null]; + $this->assertEquals($expectedArray, $payload->toArray()); + } + + #[Test] + public function canBeCreatedWithTokenOnly(): void + { + $payload = new ShareAttachmentRequestPayload(token: 'share_token_abc'); + + $this->assertSame('share_token_abc', $payload->token); + $this->assertNull($payload->url); + + $expectedArray = ['url' => null, 'token' => 'share_token_abc']; + $this->assertEquals($expectedArray, $payload->toArray()); + } + + /** + * @return array + */ + public static function invalidPayloadProvider(): array + { + return [ + 'both null' => [null, null], + 'both set' => ['https://a.com', 'token123'], + ]; + } + + #[Test] + #[DataProvider('invalidPayloadProvider')] + public function constructorThrowsExceptionForInvalidArguments(?string $url, ?string $token): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provide exactly one of "url" or "token" for ShareAttachmentRequestPayload.'); + + new ShareAttachmentRequestPayload($url, $token); + } +} diff --git a/tests/Models/Attachments/Payloads/StickerAttachmentRequestPayloadTest.php b/tests/Models/Attachments/Payloads/StickerAttachmentRequestPayloadTest.php new file mode 100644 index 0000000..e392ca0 --- /dev/null +++ b/tests/Models/Attachments/Payloads/StickerAttachmentRequestPayloadTest.php @@ -0,0 +1,32 @@ + $codeValue]; + + $stickerPayload = StickerAttachmentRequestPayload::fromArray($rawData); + + $this->assertInstanceOf(StickerAttachmentRequestPayload::class, $stickerPayload); + $this->assertSame($codeValue, $stickerPayload->code); + + $serializedData = $stickerPayload->toArray(); + $this->assertEquals($rawData, $serializedData); + + $directInstance = new StickerAttachmentRequestPayload($codeValue); + $this->assertSame($codeValue, $directInstance->code); + } +} diff --git a/tests/Models/Attachments/Payloads/UploadedInfoPayloadTest.php b/tests/Models/Attachments/Payloads/UploadedInfoPayloadTest.php new file mode 100644 index 0000000..3167b87 --- /dev/null +++ b/tests/Models/Attachments/Payloads/UploadedInfoPayloadTest.php @@ -0,0 +1,32 @@ + $tokenValue]; + + $uploadedInfoPayload = UploadedInfoAttachmentRequestPayload::fromArray($rawData); + + $this->assertInstanceOf(UploadedInfoAttachmentRequestPayload::class, $uploadedInfoPayload); + $this->assertSame($tokenValue, $uploadedInfoPayload->token); + + $serializedData = $uploadedInfoPayload->toArray(); + $this->assertEquals($rawData, $serializedData); + + $directInstance = new UploadedInfoAttachmentRequestPayload($tokenValue); + $this->assertSame($tokenValue, $directInstance->token); + } +} diff --git a/tests/Models/Attachments/Requests/AudioAttachmentRequestTest.php b/tests/Models/Attachments/Requests/AudioAttachmentRequestTest.php new file mode 100644 index 0000000..e8db0b9 --- /dev/null +++ b/tests/Models/Attachments/Requests/AudioAttachmentRequestTest.php @@ -0,0 +1,39 @@ +assertInstanceOf(AudioAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::Audio, $request->type); + $this->assertInstanceOf(UploadedInfoAttachmentRequestPayload::class, $request->payload); + $this->assertSame($token, $request->payload->token); + + $expectedArray = [ + 'type' => 'audio', + 'payload' => [ + 'token' => $token, + ], + ]; + + $this->assertEquals($expectedArray, $request->toArray()); + } +} diff --git a/tests/Models/Attachments/Requests/ContactAttachmentRequestTest.php b/tests/Models/Attachments/Requests/ContactAttachmentRequestTest.php new file mode 100644 index 0000000..05570ab --- /dev/null +++ b/tests/Models/Attachments/Requests/ContactAttachmentRequestTest.php @@ -0,0 +1,63 @@ +assertInstanceOf(ContactAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::Contact, $request->type); + $this->assertInstanceOf(ContactAttachmentRequestPayload::class, $request->payload); + $this->assertSame('John Doe', $request->payload->name); + $this->assertSame(12345, $request->payload->contactId); + + $expectedArray = [ + 'type' => 'contact', + 'payload' => [ + 'name' => 'John Doe', + 'contact_id' => 12345, + 'vcf_info' => 'BEGIN:VCARD...', + 'vcf_phone' => 'TEL:+1234567890', + ], + ]; + $this->assertEquals($expectedArray, $request->toArray()); + } + + #[Test] + public function itCreatesRequestWithOnlySomeFields(): void + { + $request = new ContactAttachmentRequest(name: 'Jane Doe', vcfPhone: 'TEL:+9876543210'); + + $expectedArray = [ + 'type' => 'contact', + 'payload' => [ + 'name' => 'Jane Doe', + 'contact_id' => null, + 'vcf_info' => null, + 'vcf_phone' => 'TEL:+9876543210', + ], + ]; + $this->assertEquals($expectedArray, $request->toArray()); + } +} diff --git a/tests/Models/Attachments/Requests/FileAttachmentRequestTest.php b/tests/Models/Attachments/Requests/FileAttachmentRequestTest.php new file mode 100644 index 0000000..6e3e96b --- /dev/null +++ b/tests/Models/Attachments/Requests/FileAttachmentRequestTest.php @@ -0,0 +1,39 @@ +assertInstanceOf(FileAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::File, $request->type); + $this->assertInstanceOf(UploadedInfoAttachmentRequestPayload::class, $request->payload); + $this->assertSame($token, $request->payload->token); + + $expectedArray = [ + 'type' => 'file', + 'payload' => [ + 'token' => $token, + ], + ]; + + $this->assertEquals($expectedArray, $request->toArray()); + } +} diff --git a/tests/Models/Attachments/Requests/InlineKeyboardAttachmentRequestTest.php b/tests/Models/Attachments/Requests/InlineKeyboardAttachmentRequestTest.php index f2d0f0f..46aec27 100644 --- a/tests/Models/Attachments/Requests/InlineKeyboardAttachmentRequestTest.php +++ b/tests/Models/Attachments/Requests/InlineKeyboardAttachmentRequestTest.php @@ -10,7 +10,7 @@ use BushlanovDev\MaxMessengerBot\Enums\Intent; use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\AbstractButton; use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\CallbackButton; use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\LinkButton; -use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\InlineKeyboardPayload; +use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\InlineKeyboardAttachmentRequestPayload; use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\InlineKeyboardAttachmentRequest; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -18,7 +18,7 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[CoversClass(InlineKeyboardAttachmentRequest::class)] -#[UsesClass(InlineKeyboardPayload::class)] +#[UsesClass(InlineKeyboardAttachmentRequestPayload::class)] #[UsesClass(AbstractButton::class)] #[UsesClass(CallbackButton::class)] #[UsesClass(LinkButton::class)] @@ -35,7 +35,7 @@ final class InlineKeyboardAttachmentRequestTest extends TestCase $this->assertInstanceOf(InlineKeyboardAttachmentRequest::class, $request); $this->assertSame(AttachmentType::InlineKeyboard, $request->type); - $this->assertInstanceOf(InlineKeyboardPayload::class, $request->payload); + $this->assertInstanceOf(InlineKeyboardAttachmentRequestPayload::class, $request->payload); $this->assertSame($buttons, $request->payload->buttons); $expectedArray = [ diff --git a/tests/Models/Attachments/Requests/LocationAttachmentRequestTest.php b/tests/Models/Attachments/Requests/LocationAttachmentRequestTest.php new file mode 100644 index 0000000..955c6d1 --- /dev/null +++ b/tests/Models/Attachments/Requests/LocationAttachmentRequestTest.php @@ -0,0 +1,42 @@ +assertInstanceOf(LocationAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::Location, $request->type); + $this->assertInstanceOf(LocationAttachmentRequestPayload::class, $request->payload); + $this->assertSame($latitude, $request->payload->latitude); + $this->assertSame($longitude, $request->payload->longitude); + + $expectedArray = [ + 'type' => 'location', + 'payload' => [ + 'latitude' => $latitude, + 'longitude' => $longitude, + ], + ]; + $this->assertEquals($expectedArray, $request->toArray()); + } +} diff --git a/tests/Models/Attachments/Requests/PhotoAttachmentRequestTest.php b/tests/Models/Attachments/Requests/PhotoAttachmentRequestTest.php index dfbc5c4..d66cc43 100644 --- a/tests/Models/Attachments/Requests/PhotoAttachmentRequestTest.php +++ b/tests/Models/Attachments/Requests/PhotoAttachmentRequestTest.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace BushlanovDev\MaxMessengerBot\Tests\Models\Attachments\Requests; use BushlanovDev\MaxMessengerBot\Enums\AttachmentType; -use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentPayload; +use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentRequestPayload; use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoToken; use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\PhotoAttachmentRequest; use InvalidArgumentException; @@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[CoversClass(PhotoAttachmentRequest::class)] -#[UsesClass(PhotoAttachmentPayload::class)] +#[UsesClass(PhotoAttachmentRequestPayload::class)] #[UsesClass(PhotoToken::class)] final class PhotoAttachmentRequestTest extends TestCase { @@ -28,7 +28,7 @@ final class PhotoAttachmentRequestTest extends TestCase $this->assertInstanceOf(PhotoAttachmentRequest::class, $request); $this->assertSame(AttachmentType::Image, $request->type); - $this->assertInstanceOf(PhotoAttachmentPayload::class, $request->payload); + $this->assertInstanceOf(PhotoAttachmentRequestPayload::class, $request->payload); $this->assertSame($url, $request->payload->url); $this->assertNull($request->payload->token); $this->assertNull($request->payload->photos); @@ -52,7 +52,7 @@ final class PhotoAttachmentRequestTest extends TestCase $this->assertInstanceOf(PhotoAttachmentRequest::class, $request); $this->assertSame(AttachmentType::Image, $request->type); - $this->assertInstanceOf(PhotoAttachmentPayload::class, $request->payload); + $this->assertInstanceOf(PhotoAttachmentRequestPayload::class, $request->payload); $this->assertSame($token, $request->payload->token); $this->assertNull($request->payload->url); $this->assertNull($request->payload->photos); @@ -79,7 +79,7 @@ final class PhotoAttachmentRequestTest extends TestCase $this->assertInstanceOf(PhotoAttachmentRequest::class, $request); $this->assertSame(AttachmentType::Image, $request->type); - $this->assertInstanceOf(PhotoAttachmentPayload::class, $request->payload); + $this->assertInstanceOf(PhotoAttachmentRequestPayload::class, $request->payload); $this->assertSame($photos, $request->payload->photos); $this->assertNull($request->payload->url); $this->assertNull($request->payload->token); @@ -119,8 +119,8 @@ final class PhotoAttachmentRequestTest extends TestCase ?array $photos ): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Provide exactly one of "url", "token", or "photos" for PhotoAttachmentPayload.'); + $this->expectExceptionMessage('Provide exactly one of "url", "token", or "photos" for PhotoAttachmentRequestPayload.'); - new PhotoAttachmentPayload($url, $token, $photos); + new PhotoAttachmentRequestPayload($url, $token, $photos); } } diff --git a/tests/Models/Attachments/Requests/ShareAttachmentRequestTest.php b/tests/Models/Attachments/Requests/ShareAttachmentRequestTest.php new file mode 100644 index 0000000..a333c26 --- /dev/null +++ b/tests/Models/Attachments/Requests/ShareAttachmentRequestTest.php @@ -0,0 +1,62 @@ +assertSame(AttachmentType::Share, $request->type); + $this->assertSame('https://dev.max.ru', $request->payload->url); + $this->assertNull($request->payload->token); + + $expected = ['type' => 'share', 'payload' => ['url' => 'https://dev.max.ru', 'token' => null]]; + $this->assertEquals($expected, $request->toArray()); + } + + #[Test] + public function fromTokenCreatesCorrectRequestAndSerializes(): void + { + $request = ShareAttachmentRequest::fromToken('share_token_123'); + $this->assertSame(AttachmentType::Share, $request->type); + $this->assertSame('share_token_123', $request->payload->token); + $this->assertNull($request->payload->url); + + $expected = ['type' => 'share', 'payload' => ['token' => 'share_token_123', 'url' => null]]; + $this->assertEquals($expected, $request->toArray()); + } + + /** @return array */ + public static function invalidPayloadProvider(): array + { + return [ + 'both null' => [null, null], + 'both set' => ['https://a.com', 'token123'], + ]; + } + + #[Test] + #[DataProvider('invalidPayloadProvider')] + public function payloadThrowsExceptionForInvalidArguments(?string $url, ?string $token): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provide exactly one of "url" or "token" for ShareAttachmentRequestPayload.'); + new ShareAttachmentRequestPayload($url, $token); + } +} diff --git a/tests/Models/Attachments/Requests/StickerAttachmentRequestTest.php b/tests/Models/Attachments/Requests/StickerAttachmentRequestTest.php new file mode 100644 index 0000000..66f6bf1 --- /dev/null +++ b/tests/Models/Attachments/Requests/StickerAttachmentRequestTest.php @@ -0,0 +1,39 @@ +assertInstanceOf(StickerAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::Sticker, $request->type); + $this->assertInstanceOf(StickerAttachmentRequestPayload::class, $request->payload); + $this->assertSame($stickerCode, $request->payload->code); + + $expectedArray = [ + 'type' => 'sticker', + 'payload' => [ + 'code' => $stickerCode, + ], + ]; + + $this->assertEquals($expectedArray, $request->toArray()); + } +} diff --git a/tests/Models/Attachments/Requests/VideoAttachmentRequestTest.php b/tests/Models/Attachments/Requests/VideoAttachmentRequestTest.php new file mode 100644 index 0000000..eaa3d27 --- /dev/null +++ b/tests/Models/Attachments/Requests/VideoAttachmentRequestTest.php @@ -0,0 +1,40 @@ +assertInstanceOf(VideoAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::Video, $request->type); + $this->assertInstanceOf(UploadedInfoAttachmentRequestPayload::class, $request->payload); + $this->assertSame($token, $request->payload->token); + + $expectedArray = [ + 'type' => 'video', + 'payload' => [ + 'token' => $token, + ], + ]; + + $this->assertEquals($expectedArray, $request->toArray()); + } +} diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php index 7166f8e..99fdc21 100644 --- a/tests/WebhookHandlerTest.php +++ b/tests/WebhookHandlerTest.php @@ -16,7 +16,11 @@ use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate; use BushlanovDev\MaxMessengerBot\WebhookHandler; use GuzzleHttp\Psr7\ServerRequest; +use LogicException; +use phpmock\phpunit\PHPMock; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\MockObject\MockObject; @@ -30,6 +34,8 @@ use PHPUnit\Framework\TestCase; #[UsesClass(MessageCreatedUpdate::class)] final class WebhookHandlerTest extends TestCase { + use PHPMock; + private MockObject&Api $apiMock; private MockObject&ModelFactory $modelFactoryMock; private const string SECRET = 'my-super-secret-key'; @@ -225,4 +231,30 @@ final class WebhookHandlerTest extends TestCase $this->assertSame($expectedUpdate, $result); } + + #[Test] + public function handleWithoutRequestWhenGuzzleIsPresent(): void + { + $this->expectException(SerializationException::class); + $this->expectExceptionMessage('Webhook body is empty.'); + + $webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock); + $webhookHandler->handle(null); + } + + #[Test] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function handleWithoutRequestWhenGuzzleIsMissing(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/No ServerRequest was provided and "guzzlehttp\/psr7" is not found/'); + + $classExistsMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'class_exists'); + + $classExistsMock->expects($this->once())->with('GuzzleHttp\Psr7\ServerRequest')->willReturn(false); + + $webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock); + $webhookHandler->handle(null); + } }