diff --git a/README.md b/README.md
index 0219b2e..4a8419c 100644
--- a/README.md
+++ b/README.md
@@ -6,8 +6,8 @@
[](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);
+ }
}