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
+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);
}
}