first commit

This commit is contained in:
Alex
2025-07-06 18:52:32 +03:00
commit bcd8a52d06
27 changed files with 1126 additions and 0 deletions
+15
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
/.gitattributes export-ignore
/.gitignore export-ignore
/.github export-ignore
/phpunit.xml export-ignore
/tests export-ignore
+61
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.idea
.php-cs-fixer.cache
.phpunit.result.cache
.phpunit.cache
composer.lock
coverage
vendor
+21
View File
@@ -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.
+5
View File
@@ -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)
+50
View File
@@ -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"
}
}
+4
View File
@@ -0,0 +1,4 @@
parameters:
level: 6
paths:
- src
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
displayDetailsOnPhpunitDeprecations="true"
failOnPhpunitDeprecation="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true">
<include>
<directory>src</directory>
</include>
<exclude>
<directory suffix=".php">src/Exceptions</directory>
</exclude>
</source>
</phpunit>
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
/**
* The main entry point for interacting with the Max Bot API.
* This class provides a clean, object-oriented interface over the raw HTTP API.
*
* @see https://dev.max.ru
*/
class Api
{
private const string METHOD_GET = 'GET';
// private const string METHOD_POST = 'POST';
private const string ACTION_ME = '/me';
private readonly ClientApiInterface $client;
private readonly ModelFactory $modelFactory;
/**
* Api constructor.
*
* @param string $accessToken Your bot's access token from @MasterBot.
* @param ClientApiInterface|null $client Http api client.
* @param ModelFactory|null $modelFactory
*/
public function __construct(string $accessToken, ?ClientApiInterface $client = null, ?ModelFactory $modelFactory = null)
{
$this->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)
);
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Exceptions\ForbiddenException;
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use BushlanovDev\MaxMessengerBot\Exceptions\NotFoundException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use BushlanovDev\MaxMessengerBot\Exceptions\UnauthorizedException;
use InvalidArgumentException;
use JsonException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* The low-level HTTP client responsible for communicating with the Max Bot API.
* It handles request signing, error handling, and JSON serialization/deserialization.
* This class is an abstraction over any PSR-18 compatible HTTP client.
*/
final class Client implements ClientApiInterface
{
private const string API_BASE_URL = 'https://botapi.max.ru';
private const string DEFAULT_API_VERSION = '0.0.6';
/**
* @param string $accessToken Your bot's access token from @MasterBot.
* @param ClientInterface $httpClient A PSR-18 compatible HTTP client (e.g., Guzzle).
* @param RequestFactoryInterface $requestFactory A PSR-17 factory for creating requests.
* @param StreamFactoryInterface $streamFactory A PSR-17 factory for creating request body streams.
* @param string $apiVersion The API version to use for requests.
*/
public function __construct(
private readonly string $accessToken,
private readonly ClientInterface $httpClient,
private readonly RequestFactoryInterface $requestFactory,
private readonly StreamFactoryInterface $streamFactory,
private readonly string $apiVersion = self::DEFAULT_API_VERSION,
) {
if (empty($accessToken)) {
throw new InvalidArgumentException('Access token cannot be empty.');
}
}
/**
* @inheritDoc
*/
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array
{
$queryParams['access_token'] = $this->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;
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
interface ClientApiInterface
{
/**
* Performs a request to the Max Bot API.
*
* @param string $method The HTTP method (GET, POST, PATCH, etc.).
* @param string $uri The API endpoint (e.g., '/me', '/messages').
* @param array<string, mixed> $queryParams Query parameters for the request.
* @param array<string, mixed> $body The request body.
*
* @return array<string, mixed> 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;
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Exceptions;
use Psr\Http\Message\ResponseInterface;
use RuntimeException;
use Throwable;
class ClientApiException extends RuntimeException
{
public function __construct(
string $message,
public readonly string $errorCode,
public readonly ?ResponseInterface $response = null,
public readonly ?int $httpStatusCode = null,
?Throwable $previous = null,
) {
parent::__construct($message, $httpStatusCode ?? 0, $previous);
}
public function getHttpStatusCode(): ?int
{
return $this->httpStatusCode;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Exceptions;
use Psr\Http\Message\ResponseInterface;
use Throwable;
class ForbiddenException extends ClientApiException
{
public function __construct(
string $message,
string $errorCode,
?ResponseInterface $response,
?Throwable $previous = null,
) {
parent::__construct($message, $errorCode, $response, 403, $previous);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Exceptions;
use RuntimeException;
use Throwable;
class NetworkException extends RuntimeException
{
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Exceptions;
use Psr\Http\Message\ResponseInterface;
use Throwable;
class NotFoundException extends ClientApiException
{
public function __construct(
string $message,
string $errorCode,
?ResponseInterface $response,
?Throwable $previous = null,
) {
parent::__construct($message, $errorCode, $response, 404, $previous);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Exceptions;
use LogicException;
use Throwable;
class SerializationException extends LogicException
{
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Exceptions;
use Psr\Http\Message\ResponseInterface;
use Throwable;
class UnauthorizedException extends ClientApiException
{
public function __construct(
string $message,
string $errorCode,
?ResponseInterface $response,
?Throwable $previous = null,
) {
parent::__construct($message, $errorCode, $response, 401, $previous);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot;
use BushlanovDev\MaxMessengerBot\Models\BotCommand;
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
/**
* Creates DTOs from raw associative arrays returned by the API client.
*/
class ModelFactory
{
/**
* @param array<string, mixed> $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);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
abstract readonly class AbstractModel
{
/**
* @param array<string, mixed> $data
*/
abstract public static function fromArray(array $data): static;
/**
* @return array<string, mixed>
*/
public function toArray(): array
{
return get_object_vars($this);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
/**
* Command supported by the bot.
*/
final readonly class BotCommand extends AbstractModel
{
/**
* @param string $name Command name (1 to 64 characters)
* @param string|null $description Command description (1 to 128 characters)
*/
public function __construct(
public string $name,
public ?string $description,
) {
}
/**
* @inheritdoc
*/
public static function fromArray(array $data): static
{
return new static(
(string)$data['name'],
$data['description'] ? (string)$data['description'] : null
);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Models;
/**
* Information about the current bot, which is identified using an access token.
*/
final readonly class BotInfo extends AbstractModel
{
/**
* @param int $user_id ID user
* @param string $first_name User display name
* @param string|null $last_name User's display last name
* @param string|null $username Unique public name of the user, may be null if the user is not available or no name is set
* @param bool $is_bot Is the user a bot
* @param int $last_activity_time User last activity time in MAX (Unix time in milliseconds). May be irrelevant if the user has disabled the "online" status in the settings
* @param string|null $description User description, may be null if the user has not filled it in (up to 16000 characters)
* @param string|null $avatar_url Avatar URL
* @param string|null $full_avatar_url Larger Avatar URL
* @param BotCommand[]|null $commands Commands supported by the bot (up to 32 elements)
*/
public function __construct(
public int $user_id,
public string $first_name,
public ?string $last_name,
public ?string $username,
public bool $is_bot,
public int $last_activity_time,
public ?string $description,
public ?string $avatar_url,
public ?string $full_avatar_url,
public ?array $commands,
) {
}
/**
* @inheritdoc
*/
public static function fromArray(array $data): static
{
return new static(
(int)$data['user_id'],
(string)$data['first_name'],
$data['last_name'] ? (string)$data['last_name'] : null,
$data['username'] ? (string)$data['username'] : null,
(bool)$data['is_bot'],
(int)$data['last_activity_time'],
$data['description'] ? (string)$data['description'] : null,
$data['avatar_url'] ? (string)$data['avatar_url'] : null,
$data['full_avatar_url'] ? (string)$data['full_avatar_url'] : null,
isset($data['commands']) && is_array($data['commands']) ? $data['commands'] : null,
);
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests;
use BushlanovDev\MaxMessengerBot\Api;
use BushlanovDev\MaxMessengerBot\Client;
use BushlanovDev\MaxMessengerBot\ClientApiInterface;
use BushlanovDev\MaxMessengerBot\ModelFactory;
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
#[CoversClass(Api::class)]
#[UsesClass(BotInfo::class)]
#[UsesClass(Client::class)]
final class ApiTest extends TestCase
{
private MockObject&ClientApiInterface $clientMock;
private MockObject&ModelFactory $modelFactoryMock;
private Api $api;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}
+203
View File
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests;
use BushlanovDev\MaxMessengerBot\Client;
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
use BushlanovDev\MaxMessengerBot\Exceptions\ForbiddenException;
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
use BushlanovDev\MaxMessengerBot\Exceptions\NotFoundException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use BushlanovDev\MaxMessengerBot\Exceptions\UnauthorizedException;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\Exception;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
#[CoversClass(Client::class)]
final class ClientTest extends TestCase
{
private const string FAKE_TOKEN = '12345:abcdef';
private const string API_VERSION = '0.0.6';
private const string API_BASE_URL = 'https://botapi.max.ru';
private MockObject&ClientInterface $httpClientMock;
private MockObject&RequestFactoryInterface $requestFactoryMock;
private MockObject&StreamFactoryInterface $streamFactoryMock;
private MockObject&RequestInterface $requestMock;
private MockObject&ResponseInterface $responseMock;
private MockObject&StreamInterface $streamMock;
private Client $client;
/**
* This method is called before each test.
*
* @throws Exception
*/
protected function setUp(): void
{
parent::setUp();
// Create mocks for all PSR interfaces
$this->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
}
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests;
use BushlanovDev\MaxMessengerBot\ModelFactory;
use BushlanovDev\MaxMessengerBot\Models\BotCommand;
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(ModelFactory::class)]
#[UsesClass(BotInfo::class)]
#[UsesClass(BotCommand::class)]
final class ModelFactoryTest extends TestCase
{
private ModelFactory $factory;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Models;
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
use BushlanovDev\MaxMessengerBot\Models\BotCommand;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(BotCommand::class)]
#[UsesClass(AbstractModel::class)]
final class BotCommandTest extends TestCase
{
#[Test]
public function canBeCreatedFromArrayWithAllData(): void
{
$data = [
'name' => '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);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace BushlanovDev\MaxMessengerBot\Tests\Models;
use BushlanovDev\MaxMessengerBot\Models\BotCommand;
use BushlanovDev\MaxMessengerBot\Models\BotInfo;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(BotInfo::class)]
#[UsesClass(BotCommand::class)]
final class BotInfoTest extends TestCase
{
#[Test]
public function canBeCreatedFromArray(): void
{
$data = [
'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' => [
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);
}
}