mirror of
https://github.com/BushlanovDev/max-bot-api-client-php.git
synced 2026-08-20 15:44:05 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b00bae23a | |||
| ca13096ec2 | |||
| 68da594f89 | |||
| 62fbb6cf3b | |||
| 97d016055f | |||
| 13e7c3fa45 |
+4
-5
@@ -1,5 +1,4 @@
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
||||
/.github export-ignore
|
||||
/phpunit.xml export-ignore
|
||||
/tests export-ignore
|
||||
/.* export-ignore
|
||||
/phpunit.xml export-ignore
|
||||
/phpstan.neon export-ignore
|
||||
/tests export-ignore
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^6.5.8||^7.0",
|
||||
"guzzlehttp/psr7": "^1.8||^2.0",
|
||||
"psr/log": "^3.0",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.0||^2.0"
|
||||
|
||||
+26
-4
@@ -37,6 +37,8 @@ use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use ReflectionException;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -48,6 +50,8 @@ use RuntimeException;
|
||||
*/
|
||||
class Api
|
||||
{
|
||||
public const string LIBRARY_VERSION = '0.9.0';
|
||||
|
||||
public const string API_VERSION = '0.0.6';
|
||||
|
||||
private const string API_BASE_URL = 'https://botapi.max.ru';
|
||||
@@ -77,12 +81,15 @@ class Api
|
||||
|
||||
private readonly ModelFactory $modelFactory;
|
||||
|
||||
private readonly LoggerInterface $logger;
|
||||
|
||||
/**
|
||||
* Api constructor.
|
||||
*
|
||||
* @param string $accessToken Your bot's access token from @MasterBot.
|
||||
* @param ClientApiInterface|null $client Http api client.
|
||||
* @param ModelFactory|null $modelFactory
|
||||
* @param LoggerInterface|null $logger
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
@@ -90,7 +97,10 @@ class Api
|
||||
string $accessToken,
|
||||
?ClientApiInterface $client = null,
|
||||
?ModelFactory $modelFactory = null,
|
||||
?LoggerInterface $logger = null,
|
||||
) {
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
|
||||
if ($client === null) {
|
||||
if (!class_exists(\GuzzleHttp\Client::class) || !class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) {
|
||||
throw new LogicException(
|
||||
@@ -99,7 +109,12 @@ class Api
|
||||
);
|
||||
}
|
||||
|
||||
$guzzle = new \GuzzleHttp\Client();
|
||||
$guzzle = new \GuzzleHttp\Client([
|
||||
'timeout' => 10,
|
||||
'connect_timeout' => 5,
|
||||
'read_timeout' => 10,
|
||||
'headers' => ['User-Agent' => 'max-bot-api-client-php/' . self::LIBRARY_VERSION . ' PHP/' . PHP_VERSION],
|
||||
]);
|
||||
$httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
|
||||
$client = new Client(
|
||||
$accessToken,
|
||||
@@ -108,6 +123,7 @@ class Api
|
||||
$httpFactory,
|
||||
self::API_BASE_URL,
|
||||
self::API_VERSION,
|
||||
$this->logger,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,7 +160,7 @@ class Api
|
||||
*/
|
||||
public function createWebhookHandler(?string $secret = null): WebhookHandler
|
||||
{
|
||||
return new WebhookHandler($this, $this->modelFactory, $secret);
|
||||
return new WebhookHandler($this, $this->modelFactory, $secret, $this->logger);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,10 +267,16 @@ class Api
|
||||
try {
|
||||
$this->processUpdatesBatch($handlers, $timeout, $marker);
|
||||
} catch (NetworkException $e) {
|
||||
error_log("Network error: " . $e->getMessage());
|
||||
$this->logger->error(
|
||||
'Long-polling network error: {message}',
|
||||
['message' => $e->getMessage(), 'exception' => $e],
|
||||
);
|
||||
sleep(5);
|
||||
} catch (\Exception $e) {
|
||||
error_log("An error occurred: " . $e->getMessage());
|
||||
$this->logger->error(
|
||||
'An error occurred during long-polling: {message}',
|
||||
['message' => $e->getMessage(), 'exception' => $e],
|
||||
);
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* The low-level HTTP client responsible for communicating with the Max Bot API.
|
||||
@@ -34,6 +36,7 @@ final readonly class Client implements ClientApiInterface
|
||||
* @param StreamFactoryInterface $streamFactory A PSR-17 factory for creating request body streams.
|
||||
* @param string $baseUrl The base URL for API requests.
|
||||
* @param string|null $apiVersion The API version to use for requests.
|
||||
* @param LoggerInterface $logger
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
@@ -44,6 +47,7 @@ final readonly class Client implements ClientApiInterface
|
||||
private StreamFactoryInterface $streamFactory,
|
||||
private string $baseUrl,
|
||||
private ?string $apiVersion = null,
|
||||
private LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
if (empty($accessToken)) {
|
||||
throw new InvalidArgumentException('Access token cannot be empty.');
|
||||
@@ -60,6 +64,12 @@ final readonly class Client implements ClientApiInterface
|
||||
$queryParams['v'] = $this->apiVersion;
|
||||
}
|
||||
|
||||
$this->logger->debug('Sending API request', [
|
||||
'method' => $method,
|
||||
'url' => $this->baseUrl . $uri,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
$fullUrl = $this->baseUrl . $uri . '?' . http_build_query($queryParams);
|
||||
$request = $this->requestFactory->createRequest($method, $fullUrl);
|
||||
|
||||
@@ -79,6 +89,10 @@ final readonly class Client implements ClientApiInterface
|
||||
$response = $this->httpClient->sendRequest($request);
|
||||
} catch (ClientExceptionInterface $e) {
|
||||
// This catches network errors, DNS failures, timeouts, etc.
|
||||
$this->logger->error('Network exception during API request', [
|
||||
'message' => $e->getMessage(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
throw new NetworkException($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
@@ -86,6 +100,11 @@ final readonly class Client implements ClientApiInterface
|
||||
|
||||
$responseBody = (string)$response->getBody();
|
||||
|
||||
$this->logger->debug('Received API response', [
|
||||
'status' => $response->getStatusCode(),
|
||||
'body' => $responseBody,
|
||||
]);
|
||||
|
||||
// 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
|
||||
@@ -161,6 +180,11 @@ final readonly class Client implements ClientApiInterface
|
||||
$errorCode = $data['code'] ?? 'unknown';
|
||||
$errorMessage = $data['message'] ?? 'An unknown error occurred.';
|
||||
|
||||
$this->logger->error('API error response received', [
|
||||
'status' => $statusCode,
|
||||
'body' => $responseBody,
|
||||
]);
|
||||
|
||||
throw match ($statusCode) {
|
||||
401 => new UnauthorizedException($errorMessage, $errorCode, $response),
|
||||
403 => new ForbiddenException($errorMessage, $errorCode, $response),
|
||||
|
||||
@@ -26,7 +26,7 @@ interface ClientApiInterface
|
||||
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array;
|
||||
|
||||
/**
|
||||
* Performs a file download at the specified URL.
|
||||
* Performs a file upload at the specified URL.
|
||||
*
|
||||
* @param string $uri URL received from the download API.
|
||||
* @param resource|string $fileContents File content (stream resource or string).
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Enums;
|
||||
|
||||
enum AttachmentType: string
|
||||
enum AttachmentType: string
|
||||
{
|
||||
case Image = 'image';
|
||||
case Video = 'video';
|
||||
|
||||
+48
-28
@@ -15,6 +15,7 @@ use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\AbstractInlin
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\CallbackButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\ChatButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\LinkButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\OpenAppButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestContactButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestGeoLocationButton;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
|
||||
@@ -47,6 +48,7 @@ use BushlanovDev\MaxMessengerBot\Models\Markup\StrongMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\UnderlineMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\UserMentionMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Message;
|
||||
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Result;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Subscription;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
|
||||
@@ -136,23 +138,54 @@ class ModelFactory
|
||||
*/
|
||||
public function createMessage(array $data): Message
|
||||
{
|
||||
if (isset($data['body']['attachments']) && is_array($data['body']['attachments'])) {
|
||||
$data['body']['attachments'] = array_map(
|
||||
[$this, 'createAttachment'],
|
||||
$data['body']['attachments'],
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($data['body']['markup']) && is_array($data['body']['markup'])) {
|
||||
$data['body']['markup'] = array_map(
|
||||
[$this, 'createMarkupElement'],
|
||||
$data['body']['markup'],
|
||||
);
|
||||
if (isset($data['body']) && is_array($data['body'])) {
|
||||
$data['body'] = $this->createMessageBody($data['body']);
|
||||
}
|
||||
|
||||
return Message::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* List of messages.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return Message[]
|
||||
*/
|
||||
public function createMessages(array $data): array
|
||||
{
|
||||
return isset($data['messages']) && is_array($data['messages'])
|
||||
? array_map([$this, 'createMessage'], $data['messages'])
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a MessageBody object from raw API data, handling polymorphic attachments and markup.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return MessageBody
|
||||
* @throws ReflectionException
|
||||
*/
|
||||
private function createMessageBody(array $data): MessageBody
|
||||
{
|
||||
if (isset($data['attachments']) && is_array($data['attachments'])) {
|
||||
$data['attachments'] = array_map(
|
||||
[$this, 'createAttachment'],
|
||||
$data['attachments'],
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($data['markup']) && is_array($data['markup'])) {
|
||||
$data['markup'] = array_map(
|
||||
[$this, 'createMarkupElement'],
|
||||
$data['markup'],
|
||||
);
|
||||
}
|
||||
|
||||
return MessageBody::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a specific Attachment model based on the 'type' field.
|
||||
*
|
||||
@@ -192,7 +225,7 @@ class ModelFactory
|
||||
AttachmentType::InlineKeyboard => InlineKeyboardAttachment::fromArray($data),
|
||||
AttachmentType::ReplyKeyboard => ReplyKeyboardAttachment::fromArray($data),
|
||||
AttachmentType::Location => LocationAttachment::fromArray($data),
|
||||
default => throw new LogicException("Unknown or unsupported attachment type: " . ($data['type'] ?? 'none')),
|
||||
default => throw new LogicException('Unknown or unsupported attachment type: ' . ($data['type'] ?? 'none')),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,24 +266,11 @@ class ModelFactory
|
||||
InlineButtonType::RequestContact => RequestContactButton::fromArray($data),
|
||||
InlineButtonType::RequestGeoLocation => RequestGeoLocationButton::fromArray($data),
|
||||
InlineButtonType::Chat => ChatButton::fromArray($data),
|
||||
default => throw new LogicException("Unknown or unsupported inline button type: " . ($data['type'] ?? 'none')),
|
||||
InlineButtonType::OpenApp => OpenAppButton::fromArray($data),
|
||||
default => throw new LogicException('Unknown or unsupported inline button type: ' . ($data['type'] ?? 'none')),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List of messages.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return Message[]
|
||||
*/
|
||||
public function createMessages(array $data): array
|
||||
{
|
||||
return isset($data['messages']) && is_array($data['messages'])
|
||||
? array_map([$this, 'createMessage'], $data['messages'])
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint you should upload to your binaries.
|
||||
*
|
||||
|
||||
@@ -12,7 +12,8 @@ final readonly class InlineKeyboardAttachment extends AbstractAttachment
|
||||
/**
|
||||
* @param KeyboardPayload $payload Keyboard payload.
|
||||
*/
|
||||
public function __construct(public KeyboardPayload $payload) {
|
||||
public function __construct(public KeyboardPayload $payload)
|
||||
{
|
||||
parent::__construct(AttachmentType::InlineKeyboard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,7 @@ final readonly class PhotoToken extends AbstractModel
|
||||
/**
|
||||
* @param string $token Encoded information of uploaded image.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $token,
|
||||
) {
|
||||
public function __construct(public string $token)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
|
||||
|
||||
final readonly class ReplyKeyboardAttachmentRequestPayload extends AbstractAttachmentRequestPayload
|
||||
@@ -15,7 +14,6 @@ final readonly class ReplyKeyboardAttachmentRequestPayload extends AbstractAttac
|
||||
* @param int|null $directUserId If set, reply keyboard will only be shown to this participant.
|
||||
*/
|
||||
public function __construct(
|
||||
#[ArrayOf(AbstractReplyButton::class)]
|
||||
public array $buttons,
|
||||
public bool $direct = false,
|
||||
public ?int $directUserId = null,
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
|
||||
|
||||
/**
|
||||
* Payload for attachments that are uploaded to the server first (video, audio, file).
|
||||
*/
|
||||
final readonly class UploadedInfoAttachmentRequestPayload extends AbstractAttachmentRequestPayload
|
||||
{
|
||||
/**
|
||||
* @param string $token The unique token received after a successful file upload.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $token,
|
||||
) {
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads;
|
||||
|
||||
/**
|
||||
* Payload for attachments that are uploaded to the server first (video, audio, file).
|
||||
*/
|
||||
final readonly class UploadedInfoAttachmentRequestPayload extends AbstractAttachmentRequestPayload
|
||||
{
|
||||
/**
|
||||
* @param string $token The unique token received after a successful file upload.
|
||||
*/
|
||||
public function __construct(public string $token)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ final readonly class ReplyKeyboardAttachment extends AbstractAttachment
|
||||
/**
|
||||
* @param AbstractReplyButton[][] $buttons
|
||||
*/
|
||||
public function __construct(public array $buttons) {
|
||||
public function __construct(public array $buttons)
|
||||
{
|
||||
parent::__construct(AttachmentType::ReplyKeyboard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ final readonly class Image extends AbstractModel
|
||||
/**
|
||||
* @param string $url URL of image.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $url,
|
||||
) {
|
||||
public function __construct(public string $url)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Models;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Attributes\ArrayOf;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\AbstractAttachment;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\AbstractMarkup;
|
||||
|
||||
@@ -24,9 +23,7 @@ final readonly class MessageBody extends AbstractModel
|
||||
public string $mid,
|
||||
public int $seq,
|
||||
public ?string $text,
|
||||
#[ArrayOf(AbstractAttachment::class)]
|
||||
public ?array $attachments,
|
||||
#[ArrayOf(AbstractMarkup::class)]
|
||||
public ?array $markup,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* A class designed to process incoming webhook requests from the Max API.
|
||||
@@ -26,11 +28,13 @@ final class WebhookHandler
|
||||
* @param Api $api An instance of the Api to be passed to handlers for immediate responses.
|
||||
* @param ModelFactory $modelFactory An instance of the model factory to create Update objects.
|
||||
* @param string|null $secret The secret key provided during webhook subscription to verify requests.
|
||||
* @param LoggerInterface $logger A PSR-3 compatible logger.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Api $api,
|
||||
private readonly ModelFactory $modelFactory,
|
||||
private readonly ?string $secret = null,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -254,6 +258,8 @@ final class WebhookHandler
|
||||
$payload = (string)$request->getBody();
|
||||
$signature = $request->getHeaderLine('X-Max-Bot-Api-Secret');
|
||||
|
||||
$this->logger->debug('Received webhook payload', ['body' => $payload]);
|
||||
|
||||
if (empty($payload)) {
|
||||
throw new SerializationException('Webhook body is empty.');
|
||||
}
|
||||
@@ -263,6 +269,7 @@ final class WebhookHandler
|
||||
try {
|
||||
$data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
$this->logger->error('Failed to decode webhook JSON', ['payload' => $payload, 'exception' => $e]);
|
||||
throw new SerializationException('Failed to decode webhook body as JSON.', 0, $e);
|
||||
}
|
||||
|
||||
@@ -297,6 +304,7 @@ final class WebhookHandler
|
||||
}
|
||||
|
||||
if (!hash_equals($this->secret, $signature)) {
|
||||
$this->logger->warning('Webhook signature verification failed', ['received_signature' => $signature]);
|
||||
throw new SecurityException('Signature verification failed.');
|
||||
}
|
||||
}
|
||||
|
||||
+11
-7
@@ -70,6 +70,7 @@ use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\Exception;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -122,6 +123,8 @@ final class ApiTest extends TestCase
|
||||
|
||||
private MockObject&ClientApiInterface $clientMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
|
||||
private Api $api;
|
||||
|
||||
/**
|
||||
@@ -133,8 +136,9 @@ final class ApiTest extends TestCase
|
||||
|
||||
$this->clientMock = $this->createMock(ClientApiInterface::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock);
|
||||
$this->api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock, $this->loggerMock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -773,10 +777,12 @@ final class ApiTest extends TestCase
|
||||
$handlers = [UpdateType::MessageCreated->value => fn() => null];
|
||||
|
||||
$apiMock = $this->getMockBuilder(Api::class)
|
||||
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock])
|
||||
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock, $this->loggerMock])
|
||||
->onlyMethods(['processUpdatesBatch'])
|
||||
->getMock();
|
||||
|
||||
$this->loggerMock->expects($this->once())->method('error');
|
||||
|
||||
$apiMock->expects($this->any())
|
||||
->method('processUpdatesBatch')
|
||||
->willReturnCallback(function () {
|
||||
@@ -790,8 +796,6 @@ final class ApiTest extends TestCase
|
||||
}
|
||||
});
|
||||
|
||||
$this->expectOutputRegex('/Network error: Simulated network error/');
|
||||
|
||||
try {
|
||||
$apiMock->handleUpdates($handlers);
|
||||
} catch (\Error $e) {
|
||||
@@ -828,10 +832,12 @@ final class ApiTest extends TestCase
|
||||
$handlers = [UpdateType::MessageCreated->value => fn() => null];
|
||||
|
||||
$apiMock = $this->getMockBuilder(Api::class)
|
||||
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock])
|
||||
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock, $this->loggerMock])
|
||||
->onlyMethods(['processUpdatesBatch'])
|
||||
->getMock();
|
||||
|
||||
$this->loggerMock->expects($this->once())->method('error');
|
||||
|
||||
$apiMock->expects($this->any())
|
||||
->method('processUpdatesBatch')
|
||||
->willReturnCallback(function () {
|
||||
@@ -847,8 +853,6 @@ final class ApiTest extends TestCase
|
||||
}
|
||||
});
|
||||
|
||||
$this->expectOutputRegex('/An error occurred: Simulated JSON error/');
|
||||
|
||||
try {
|
||||
$apiMock->handleUpdates($handlers);
|
||||
} catch (\Error $e) {
|
||||
|
||||
@@ -28,6 +28,7 @@ use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[CoversClass(Client::class)]
|
||||
final class ClientTest extends TestCase
|
||||
@@ -42,6 +43,7 @@ final class ClientTest extends TestCase
|
||||
private MockObject&RequestInterface $requestMock;
|
||||
private MockObject&ResponseInterface $responseMock;
|
||||
private MockObject&StreamInterface $streamMock;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
|
||||
private Client $client;
|
||||
|
||||
@@ -61,6 +63,7 @@ final class ClientTest extends TestCase
|
||||
$this->requestMock = $this->createMock(RequestInterface::class);
|
||||
$this->responseMock = $this->createMock(ResponseInterface::class);
|
||||
$this->streamMock = $this->createMock(StreamInterface::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
|
||||
// Common mock setups
|
||||
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
|
||||
@@ -75,6 +78,7 @@ final class ClientTest extends TestCase
|
||||
$this->streamFactory,
|
||||
self::API_BASE_URL,
|
||||
self::API_VERSION,
|
||||
$this->loggerMock,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,4 +380,33 @@ final class ClientTest extends TestCase
|
||||
$this->streamMock->method('__toString')->willReturn('{not-a-valid-json');
|
||||
$this->client->upload('http://some.url', 'content', 'file.txt');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function requestLogsRequestAndResponseOnDebugLevel(): void
|
||||
{
|
||||
$this->responseMock->method('getStatusCode')->willReturn(200);
|
||||
$this->streamMock->method('__toString')->willReturn('{"success":true}');
|
||||
|
||||
$this->loggerMock
|
||||
->expects($this->exactly(2))
|
||||
->method('debug');
|
||||
|
||||
$this->client->request('GET', '/me');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleErrorResponseLogsWarning(): void
|
||||
{
|
||||
$this->responseMock->method('getStatusCode')->willReturn(404);
|
||||
$this->streamMock->method('__toString')->willReturn('{"code":"not.found","message":"Not Found"}');
|
||||
|
||||
$this->loggerMock
|
||||
->expects($this->once())
|
||||
->method('error')
|
||||
->with('API error response received', $this->anything());
|
||||
|
||||
$this->expectException(NotFoundException::class);
|
||||
|
||||
$this->client->request('GET', '/not/found');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,23 @@ final class OpenAppButtonTest extends TestCase
|
||||
|
||||
$this->assertSame($expectedArray, $resultArray);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function fromArrayHydratesCorrectly(): void
|
||||
{
|
||||
$data = [
|
||||
'type' => 'open_app',
|
||||
'text' => 'Launch',
|
||||
'web_app' => 'SomeApp',
|
||||
'contact_id' => 456,
|
||||
];
|
||||
|
||||
$button = OpenAppButton::fromArray($data);
|
||||
|
||||
$this->assertInstanceOf(OpenAppButton::class, $button);
|
||||
$this->assertSame(InlineButtonType::OpenApp, $button->type);
|
||||
$this->assertSame('Launch', $button->text);
|
||||
$this->assertSame('SomeApp', $button->webApp);
|
||||
$this->assertSame(456, $button->contactId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,35 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests\Models;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\AbstractAttachment;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\ContactAttachment;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\ContactAttachmentPayload;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Payloads\PhotoAttachmentPayload;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\PhotoAttachment;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\AbstractMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Markup\StrongMarkup;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Message;
|
||||
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Recipient;
|
||||
use BushlanovDev\MaxMessengerBot\Models\User;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(MessageBody::class)]
|
||||
#[UsesClass(AbstractAttachment::class)]
|
||||
#[UsesClass(ContactAttachment::class)]
|
||||
#[UsesClass(ContactAttachmentPayload::class)]
|
||||
#[UsesClass(PhotoAttachmentPayload::class)]
|
||||
#[UsesClass(PhotoAttachment::class)]
|
||||
#[UsesClass(ModelFactory::class)]
|
||||
#[UsesClass(AbstractMarkup::class)]
|
||||
#[UsesClass(StrongMarkup::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
#[UsesClass(Recipient::class)]
|
||||
#[UsesClass(User::class)]
|
||||
final class MessageBodyTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
@@ -59,4 +82,57 @@ final class MessageBodyTest extends TestCase
|
||||
$this->assertIsArray($array);
|
||||
$this->assertSame($data, $array);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createMessageCorrectlyHydratesComplexMessageBody(): void
|
||||
{
|
||||
$messageData = [
|
||||
'timestamp' => time(),
|
||||
'body' => [
|
||||
'mid' => 'mid.poly.test',
|
||||
'seq' => 200,
|
||||
'text' => 'Message with mixed content',
|
||||
'attachments' => [
|
||||
[
|
||||
'type' => 'contact',
|
||||
'payload' => [
|
||||
'vcf_info' => 'vcf...',
|
||||
'max_info' => [
|
||||
'user_id' => 1111,
|
||||
'first_name' => 'aaaaa',
|
||||
'is_bot' => false,
|
||||
'last_activity_time' => 1754385571000,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'type' => 'image',
|
||||
'payload' => ['photo_id' => 1, 'token' => 't', 'url' => 'u'],
|
||||
]
|
||||
],
|
||||
'markup' => [
|
||||
['type' => 'strong', 'from' => 0, 'length' => 7],
|
||||
]
|
||||
],
|
||||
'recipient' => ['chat_type' => 'dialog', 'user_id' => 123],
|
||||
];
|
||||
|
||||
$factory = new ModelFactory();
|
||||
$message = $factory->createMessage($messageData);
|
||||
|
||||
$this->assertInstanceOf(Message::class, $message);
|
||||
$this->assertInstanceOf(MessageBody::class, $message->body);
|
||||
|
||||
$attachments = $message->body->attachments;
|
||||
$this->assertIsArray($attachments);
|
||||
$this->assertCount(2, $attachments);
|
||||
$this->assertInstanceOf(ContactAttachment::class, $attachments[0]);
|
||||
$this->assertInstanceOf(PhotoAttachment::class, $attachments[1]);
|
||||
|
||||
$markup = $message->body->markup;
|
||||
$this->assertIsArray($markup);
|
||||
$this->assertCount(1, $markup);
|
||||
$this->assertInstanceOf(StrongMarkup::class, $markup[0]);
|
||||
$this->assertSame(0, $markup[0]->from);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[CoversClass(WebhookHandler::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
@@ -38,6 +39,8 @@ final class WebhookHandlerTest extends TestCase
|
||||
|
||||
private MockObject&Api $apiMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
|
||||
private const string SECRET = 'my-super-secret-key';
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -45,6 +48,7 @@ final class WebhookHandlerTest extends TestCase
|
||||
parent::setUp();
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
}
|
||||
|
||||
private function createValidUpdatePayload(): string
|
||||
@@ -261,4 +265,21 @@ final class WebhookHandlerTest extends TestCase
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->handle(null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function verifySignatureLogsWarningOnFailure(): void
|
||||
{
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'wrong-signature'], $this->createValidUpdatePayload()
|
||||
);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET, $this->loggerMock);
|
||||
|
||||
$this->loggerMock
|
||||
->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Webhook signature verification failed', ['received_signature' => 'wrong-signature']);
|
||||
|
||||
$this->expectException(SecurityException::class);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user