From bcd8a52d069aef8ccc6e8f4fea0ab1367aedc823 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 6 Jul 2025 18:52:32 +0300 Subject: [PATCH] first commit --- .editorconfig | 15 ++ .gitattributes | 5 + .github/workflows/ci.yml | 61 +++++++ .gitignore | 7 + LICENSE | 21 +++ README.md | 5 + composer.json | 50 ++++++ phpstan.neon | 4 + phpunit.xml | 28 +++ src/Api.php | 56 ++++++ src/Client.php | 127 ++++++++++++++ src/ClientApiInterface.php | 28 +++ src/Exceptions/ClientApiException.php | 27 +++ src/Exceptions/ForbiddenException.php | 20 +++ src/Exceptions/NetworkException.php | 16 ++ src/Exceptions/NotFoundException.php | 20 +++ src/Exceptions/SerializationException.php | 16 ++ src/Exceptions/UnauthorizedException.php | 20 +++ src/ModelFactory.php | 27 +++ src/Models/AbstractModel.php | 21 +++ src/Models/BotCommand.php | 32 ++++ src/Models/BotInfo.php | 56 ++++++ tests/ApiTest.php | 81 +++++++++ tests/ClientTest.php | 203 ++++++++++++++++++++++ tests/ModelFactoryTest.php | 81 +++++++++ tests/Models/BotCommandTest.php | 52 ++++++ tests/Models/BotInfoTest.php | 47 +++++ 27 files changed, 1126 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/Api.php create mode 100644 src/Client.php create mode 100644 src/ClientApiInterface.php create mode 100644 src/Exceptions/ClientApiException.php create mode 100644 src/Exceptions/ForbiddenException.php create mode 100644 src/Exceptions/NetworkException.php create mode 100644 src/Exceptions/NotFoundException.php create mode 100644 src/Exceptions/SerializationException.php create mode 100644 src/Exceptions/UnauthorizedException.php create mode 100644 src/ModelFactory.php create mode 100644 src/Models/AbstractModel.php create mode 100644 src/Models/BotCommand.php create mode 100644 src/Models/BotInfo.php create mode 100644 tests/ApiTest.php create mode 100644 tests/ClientTest.php create mode 100644 tests/ModelFactoryTest.php create mode 100644 tests/Models/BotCommandTest.php create mode 100644 tests/Models/BotInfoTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0bdfc71 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..83ebb44 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/.github export-ignore +/phpunit.xml export-ignore +/tests export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d3d1035 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: [ push, pull_request ] + +jobs: + + phpstan: + + name: phpstan + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + coverage: none + + - name: Install composer dependencies + run: composer install --prefer-dist --no-interaction + + - name: Run Static Analysis + run: ./vendor/bin/phpstan analyse -c phpstan.neon --error-format=github + + tests: + + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ "8.3", "8.4" ] + name: PHP ${{ matrix.php-versions }} Test on ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + coverage: xdebug + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install composer dependencies + run: composer install --prefer-dist --no-interaction + + - name: PHPUnit + run: ./vendor/bin/phpunit --configuration=phpunit.xml --coverage-text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52d1aee --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +.php-cs-fixer.cache +.phpunit.result.cache +.phpunit.cache +composer.lock +coverage +vendor diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee7a200 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Aleksandr Bushlanov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..037e38f --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Max Bot API Client library for Golang + +[![Actions status](https://github.com/BushlanovDev/max-bot-api-client-php/actions/workflows/ci.yml/badge.svg?style=flat-square)](https://github.com/BushlanovDev/max-bot-api-client-php/actions) +[![PHP Version](https://img.shields.io/packagist/php-v/bushlanov-dev/max-bot-api-client-php.svg?style=flat-square)]() +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e15f04d --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "bushlanov-dev/max-bot-api-client-php", + "description": "", + "keywords": [ + "max messenger", + "bot", + "max", + "api" + ], + "type": "project", + "license": "MIT", + "authors": [ + { + "name": "Aleksandr Bushlanov", + "email": "alex@bushlanov.dev" + } + ], + "require": { + "php": ">=8.3", + "ext-json": "*", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "guzzlehttp/guzzle": "^6.0|^7.0" + }, + "require-dev": { + "roave/security-advisories": "dev-latest", + "phpunit/phpunit": "^12.0", + "phpstan/phpstan": "^2.1", + "friendsofphp/php-cs-fixer": "^3.77" + }, + "autoload": { + "psr-4": { + "BushlanovDev\\MaxMessengerBot\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "BushlanovDev\\MaxMessengerBot\\Tests\\": "tests" + } + }, + "config": { + "sort-packages": true + }, + "scripts": { + "analyse": "vendor/bin/phpstan analyse -c phpstan.neon", + "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes src", + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-html coverage" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..d7f8277 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 6 + paths: + - src diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..8b6e5ff --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,28 @@ + + + + + tests + + + + + + src + + + src/Exceptions + + + diff --git a/src/Api.php b/src/Api.php new file mode 100644 index 0000000..00982c9 --- /dev/null +++ b/src/Api.php @@ -0,0 +1,56 @@ +client = $client ?? new Client( + $accessToken, + new \GuzzleHttp\Client(), + new \GuzzleHttp\Psr7\HttpFactory(), + new \GuzzleHttp\Psr7\HttpFactory(), + ); + $this->modelFactory = $modelFactory ?? new ModelFactory(); + } + + /** + * Returns information about the current bot, identified by an access token. + * + * @return BotInfo + */ + public function getBotInfo(): BotInfo + { + return $this->modelFactory->createBotInfo( + $this->client->request(self::METHOD_GET, self::ACTION_ME) + ); + } +} diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..e4a2df4 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,127 @@ +accessToken; + $queryParams['v'] = $this->apiVersion; + + $fullUrl = self::API_BASE_URL . $uri . '?' . http_build_query($queryParams); + $request = $this->requestFactory->createRequest($method, $fullUrl); + + if (!empty($body)) { + try { + $payload = json_encode($body, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new SerializationException('Failed to encode request body to JSON.', 0, $e); + } + $stream = $this->streamFactory->createStream($payload); + $request = $request + ->withBody($stream) + ->withHeader('Content-Type', 'application/json; charset=utf-8'); + } + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + // This catches network errors, DNS failures, timeouts, etc. + throw new NetworkException($e->getMessage(), $e->getCode(), $e); + } + + $this->handleErrorResponse($response); + + $responseBody = (string)$response->getBody(); + + // Handle successful but empty responses (e.g., from DELETE endpoints) + if (empty($responseBody)) { + // The API spec often returns {"success": true}, so we can simulate that + // for consistency if the body is truly empty. + return ['success' => true]; + } + + try { + return json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new SerializationException('Failed to decode API response JSON.', 0, $e); + } + } + + /** + * Checks the response for an error status code and throws a corresponding typed exception. + * + * @throws ClientApiException + */ + private function handleErrorResponse(ResponseInterface $response): void + { + $statusCode = $response->getStatusCode(); + + // 2xx codes are considered successful. + if ($statusCode >= 200 && $statusCode < 300) { + return; + } + + $responseBody = (string)$response->getBody(); + $data = json_decode($responseBody, true) ?? []; + $errorCode = $data['code'] ?? 'unknown'; + $errorMessage = $data['message'] ?? 'An unknown error occurred.'; + + $exception = match ($statusCode) { + 401 => new UnauthorizedException($errorMessage, $errorCode, $response), + 403 => new ForbiddenException($errorMessage, $errorCode, $response), + 404 => new NotFoundException($errorMessage, $errorCode, $response), + default => new ClientApiException($errorMessage, $errorCode, $response, $statusCode), + }; + + throw $exception; + } +} diff --git a/src/ClientApiInterface.php b/src/ClientApiInterface.php new file mode 100644 index 0000000..413d81f --- /dev/null +++ b/src/ClientApiInterface.php @@ -0,0 +1,28 @@ + $queryParams Query parameters for the request. + * @param array $body The request body. + * + * @return array The decoded JSON response as an associative array. + * + * @throws ClientApiException for API-level errors (4xx, 5xx). + * @throws NetworkException for network-related issues. + * @throws SerializationException for JSON encoding/decoding failures. + */ + public function request(string $method, string $uri, array $queryParams = [], array $body = []): array; +} diff --git a/src/Exceptions/ClientApiException.php b/src/Exceptions/ClientApiException.php new file mode 100644 index 0000000..625e7c5 --- /dev/null +++ b/src/Exceptions/ClientApiException.php @@ -0,0 +1,27 @@ +httpStatusCode; + } +} diff --git a/src/Exceptions/ForbiddenException.php b/src/Exceptions/ForbiddenException.php new file mode 100644 index 0000000..cd59110 --- /dev/null +++ b/src/Exceptions/ForbiddenException.php @@ -0,0 +1,20 @@ + $data + * + * @return BotInfo + */ + public function createBotInfo(array $data): BotInfo + { + $data['commands'] = isset($data['commands']) && is_array($data['commands']) + ? array_map([BotCommand::class, 'fromArray'], $data['commands']) : null; + + return BotInfo::fromArray($data); + } +} diff --git a/src/Models/AbstractModel.php b/src/Models/AbstractModel.php new file mode 100644 index 0000000..35e5c9e --- /dev/null +++ b/src/Models/AbstractModel.php @@ -0,0 +1,21 @@ + $data + */ + abstract public static function fromArray(array $data): static; + + /** + * @return array + */ + public function toArray(): array + { + return get_object_vars($this); + } +} diff --git a/src/Models/BotCommand.php b/src/Models/BotCommand.php new file mode 100644 index 0000000..ede5c20 --- /dev/null +++ b/src/Models/BotCommand.php @@ -0,0 +1,32 @@ +clientMock = $this->createMock(ClientApiInterface::class); + $this->modelFactoryMock = $this->createMock(ModelFactory::class); + + $this->api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock); + } + + #[Test] + public function constructorCanCreateDefaultDependencies(): void + { + $api = new Api('some-token'); + + $reflection = new ReflectionClass($api); + + $clientProp = $reflection->getProperty('client'); + $clientProp->setAccessible(true); + $this->assertInstanceOf(ClientApiInterface::class, $clientProp->getValue($api)); + + $factoryProp = $reflection->getProperty('modelFactory'); + $factoryProp->setAccessible(true); + $this->assertInstanceOf(ModelFactory::class, $factoryProp->getValue($api)); + } + + #[Test] + public function getBotInfoCallsClientAndFactoryCorrectly(): void + { + $rawResponseData = ['user_id' => 123, 'first_name' => 'ApiTestBot']; + + $expectedBotInfo = new BotInfo( + 123, + 'ApiTestBot', + null, null, true, 0, null, null, null, null, + ); + + $this->clientMock + ->expects($this->once()) + ->method('request') + ->with('GET', '/me') + ->willReturn($rawResponseData); + + $this->modelFactoryMock + ->expects($this->once()) + ->method('createBotInfo') + ->with($rawResponseData) + ->willReturn($expectedBotInfo); + + $result = $this->api->getBotInfo(); + + $this->assertSame($expectedBotInfo, $result); + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 0000000..ffba993 --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,203 @@ +httpClientMock = $this->createMock(ClientInterface::class); + $this->requestFactoryMock = $this->createMock(RequestFactoryInterface::class); + $this->streamFactoryMock = $this->createMock(StreamFactoryInterface::class); + $this->requestMock = $this->createMock(RequestInterface::class); + $this->responseMock = $this->createMock(ResponseInterface::class); + $this->streamMock = $this->createMock(StreamInterface::class); + + // Common mock setups + $this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock); + $this->responseMock->method('getBody')->willReturn($this->streamMock); + $this->httpClientMock->method('sendRequest')->willReturn($this->responseMock); + $this->requestMock->method('withBody')->willReturn($this->requestMock); + $this->requestMock->method('withHeader')->willReturn($this->requestMock); + + // Instantiate the System Under Test (SUT) + $this->client = new Client( + self::FAKE_TOKEN, + $this->httpClientMock, + $this->requestFactoryMock, + $this->streamFactoryMock, + self::API_VERSION, + ); + } + + #[Test] + public function constructorThrowsExceptionOnEmptyToken(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Access token cannot be empty.'); + + new Client('', $this->httpClientMock, $this->requestFactoryMock, $this->streamFactoryMock); + } + + #[Test] + public function successfulGetRequest(): void + { + $uri = '/me'; + $expectedUrl = self::API_BASE_URL . $uri . '?' . http_build_query([ + 'access_token' => self::FAKE_TOKEN, + 'v' => self::API_VERSION, + ]); + $responsePayload = ['ok' => true, 'result' => ['id' => 987, 'name' => 'TestBot']]; + + // Configure mocks for this specific test + $this->requestFactoryMock + ->expects($this->once()) + ->method('createRequest') + ->with('GET', $expectedUrl) + ->willReturn($this->requestMock); + + $this->httpClientMock + ->expects($this->once()) + ->method('sendRequest') + ->with($this->requestMock) + ->willReturn($this->responseMock); + + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->streamMock->method('__toString')->willReturn(json_encode($responsePayload)); + + // Execute and assert + $result = $this->client->request('GET', $uri); + $this->assertSame($responsePayload, $result); + } + + #[Test] + public function throwsNetworkExceptionOnClientError(): void + { + $this->expectException(NetworkException::class); + + // Create a generic PSR-18 exception + $psrException = new class extends \Exception implements ClientExceptionInterface {}; + + $this->httpClientMock + ->method('sendRequest') + ->willThrowException($psrException); + + $this->client->request('GET', '/me'); + } + + #[Test] + public function throwsSerializationExceptionOnInvalidJsonResponse(): void + { + $this->expectException(SerializationException::class); + $this->expectExceptionMessage('Failed to decode API response JSON.'); + + $this->responseMock->method('getStatusCode')->willReturn(200); + $this->streamMock->method('__toString')->willReturn('{not-valid-json'); + + $this->client->request('GET', '/me'); + } + + #[Test] + public function throwsSerializationExceptionOnInvalidRequestBody(): void + { + $this->expectException(SerializationException::class); + $this->expectExceptionMessage('Failed to encode request body to JSON.'); + + // \NAN cannot be encoded in JSON + $invalidBody = ['value' => \NAN]; + + $this->client->request('POST', '/messages', [], $invalidBody); + } + + /** + * Data provider for testing various API error status codes. + */ + public static function apiErrorProvider(): array + { + return [ + '401 Unauthorized' => [401, UnauthorizedException::class, 'verify.token', 'Invalid access_token'], + '403 Forbidden' => [403, ForbiddenException::class, 'access.denied', 'You don\'t have permissions'], + '404 Not Found' => [404, NotFoundException::class, 'not.found', 'Resource not found'], + '400 Bad Request' => [400, ClientApiException::class, 'bad.request', 'Invalid parameters'], + '503 Service Unavailable' => [ + 503, + ClientApiException::class, + 'service.unavailable', + 'Service is temporarily unavailable', + ], + ]; + } + + #[Test] + #[DataProvider('apiErrorProvider')] + public function throwsCorrectExceptionForApiErrorStatusCodes( + int $statusCode, + string $exceptionClass, + string $errorCode, + string $errorMessage, + ): void { + $this->expectException($exceptionClass); + $this->expectExceptionMessage($errorMessage); + + $errorPayload = json_encode(['code' => $errorCode, 'message' => $errorMessage]); + + $this->responseMock->method('getStatusCode')->willReturn($statusCode); + $this->streamMock->method('__toString')->willReturn($errorPayload); + + try { + $this->client->request('GET', '/some/failing/endpoint'); + } catch (ClientApiException $e) { + // Also assert the specific properties of our custom exception + $this->assertSame($statusCode, $e->getHttpStatusCode()); + $this->assertSame($errorCode, $e->errorCode); + $this->assertSame($this->responseMock, $e->response); + throw $e; // Re-throw for PHPUnit to catch the expected exception type + } + } +} diff --git a/tests/ModelFactoryTest.php b/tests/ModelFactoryTest.php new file mode 100644 index 0000000..ff16018 --- /dev/null +++ b/tests/ModelFactoryTest.php @@ -0,0 +1,81 @@ +factory = new ModelFactory(); + } + + #[Test] + public function createBotInfoCorrectlyHydratesCommands(): void + { + $rawData = [ + 'user_id' => 12345, + 'first_name' => 'Test', + 'last_name' => 'Bot', + 'username' => 'test_bot', + 'is_bot' => true, + 'last_activity_time' => 1678886400000, + 'description' => 'A test bot.', + 'avatar_url' => 'http://example.com/avatar.jpg', + 'full_avatar_url' => 'http://example.com/full_avatar.jpg', + 'commands' => [ + ['name' => 'start', 'description' => 'Start the bot'], + ['name' => 'help', 'description' => 'Show help'], + ], + ]; + + $botInfo = $this->factory->createBotInfo($rawData); + + $this->assertInstanceOf(BotInfo::class, $botInfo); + $this->assertSame(12345, $botInfo->user_id); + + $this->assertIsArray($botInfo->commands); + $this->assertCount(2, $botInfo->commands); + $this->assertInstanceOf(BotCommand::class, $botInfo->commands[0]); + $this->assertSame('start', $botInfo->commands[0]->name); + $this->assertInstanceOf(BotCommand::class, $botInfo->commands[1]); + $this->assertSame('help', $botInfo->commands[1]->name); + } + + #[Test] + public function createBotInfoHandlesNullCommands(): void + { + $rawData = [ + 'user_id' => 12345, + 'first_name' => 'Test', + 'last_name' => null, + 'username' => 'test_bot', + 'is_bot' => true, + 'last_activity_time' => 1678886400000, + 'description' => null, + 'avatar_url' => null, + 'full_avatar_url' => null, + 'commands' => null, + ]; + + $botInfo = $this->factory->createBotInfo($rawData); + + $this->assertInstanceOf(BotInfo::class, $botInfo); + $this->assertNull($botInfo->commands); + } +} diff --git a/tests/Models/BotCommandTest.php b/tests/Models/BotCommandTest.php new file mode 100644 index 0000000..a492395 --- /dev/null +++ b/tests/Models/BotCommandTest.php @@ -0,0 +1,52 @@ + 'start', + 'description' => 'Start the bot', + ]; + + $command = BotCommand::fromArray($data); + + $this->assertInstanceOf(BotCommand::class, $command); + $this->assertSame('start', $command->name); + $this->assertSame('Start the bot', $command->description); + + $arrayResult = $command->toArray(); + + $this->assertIsArray($arrayResult); + $this->assertSame($data, $arrayResult); + } + + #[Test] + public function canBeCreatedFromArrayWithOptionalDataNull(): void + { + $data = [ + 'name' => 'help', + 'description' => null, + ]; + + $command = BotCommand::fromArray($data); + + $this->assertInstanceOf(BotCommand::class, $command); + $this->assertSame('help', $command->name); + $this->assertNull($command->description); + } +} diff --git a/tests/Models/BotInfoTest.php b/tests/Models/BotInfoTest.php new file mode 100644 index 0000000..aa97e6f --- /dev/null +++ b/tests/Models/BotInfoTest.php @@ -0,0 +1,47 @@ + 12345, + 'first_name' => 'Test', + 'last_name' => 'Bot', + 'username' => 'test_bot', + 'is_bot' => true, + 'last_activity_time' => 1678886400000, + 'description' => 'A test bot.', + 'avatar_url' => 'http://example.com/avatar.jpg', + 'full_avatar_url' => 'http://example.com/full_avatar.jpg', + 'commands' => [ + new BotCommand('start', 'Start the bot'), + new BotCommand('help', 'Show help'), + ], + ]; + + $botInfo = BotInfo::fromArray($data); + + $this->assertInstanceOf(BotInfo::class, $botInfo); + $this->assertSame(12345, $botInfo->user_id); + $this->assertSame('Test', $botInfo->first_name); + $this->assertTrue($botInfo->is_bot); + $this->assertCount(2, $botInfo->commands); + $this->assertInstanceOf(BotCommand::class, $botInfo->commands[0]); + $this->assertSame('start', $botInfo->commands[0]->name); + } +}