Compare commits

..

3 Commits

Author SHA1 Message Date
Alex 1b00bae23a Some fix 2025-08-07 22:19:10 +03:00
Alex ca13096ec2 Added PSR LoggerInterface 2025-08-07 19:04:07 +03:00
Alex 68da594f89 Added OpenAppButton factory method 2025-08-06 20:52:37 +03:00
11 changed files with 150 additions and 17 deletions
+1
View File
@@ -17,6 +17,7 @@
"ext-json": "*", "ext-json": "*",
"guzzlehttp/guzzle": "^6.5.8||^7.0", "guzzlehttp/guzzle": "^6.5.8||^7.0",
"guzzlehttp/psr7": "^1.8||^2.0", "guzzlehttp/psr7": "^1.8||^2.0",
"psr/log": "^3.0",
"psr/http-client": "^1.0", "psr/http-client": "^1.0",
"psr/http-factory": "^1.0", "psr/http-factory": "^1.0",
"psr/http-message": "^1.0||^2.0" "psr/http-message": "^1.0||^2.0"
+26 -4
View File
@@ -37,6 +37,8 @@ use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
use InvalidArgumentException; use InvalidArgumentException;
use LogicException; use LogicException;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use ReflectionException; use ReflectionException;
use RuntimeException; use RuntimeException;
@@ -48,6 +50,8 @@ use RuntimeException;
*/ */
class Api class Api
{ {
public const string LIBRARY_VERSION = '0.9.0';
public const string API_VERSION = '0.0.6'; public const string API_VERSION = '0.0.6';
private const string API_BASE_URL = 'https://botapi.max.ru'; private const string API_BASE_URL = 'https://botapi.max.ru';
@@ -77,12 +81,15 @@ class Api
private readonly ModelFactory $modelFactory; private readonly ModelFactory $modelFactory;
private readonly LoggerInterface $logger;
/** /**
* Api constructor. * Api constructor.
* *
* @param string $accessToken Your bot's access token from @MasterBot. * @param string $accessToken Your bot's access token from @MasterBot.
* @param ClientApiInterface|null $client Http api client. * @param ClientApiInterface|null $client Http api client.
* @param ModelFactory|null $modelFactory * @param ModelFactory|null $modelFactory
* @param LoggerInterface|null $logger
* *
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
@@ -90,7 +97,10 @@ class Api
string $accessToken, string $accessToken,
?ClientApiInterface $client = null, ?ClientApiInterface $client = null,
?ModelFactory $modelFactory = null, ?ModelFactory $modelFactory = null,
?LoggerInterface $logger = null,
) { ) {
$this->logger = $logger ?? new NullLogger();
if ($client === null) { if ($client === null) {
if (!class_exists(\GuzzleHttp\Client::class) || !class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) { if (!class_exists(\GuzzleHttp\Client::class) || !class_exists(\GuzzleHttp\Psr7\HttpFactory::class)) {
throw new LogicException( 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(); $httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
$client = new Client( $client = new Client(
$accessToken, $accessToken,
@@ -108,6 +123,7 @@ class Api
$httpFactory, $httpFactory,
self::API_BASE_URL, self::API_BASE_URL,
self::API_VERSION, self::API_VERSION,
$this->logger,
); );
} }
@@ -144,7 +160,7 @@ class Api
*/ */
public function createWebhookHandler(?string $secret = null): WebhookHandler 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 { try {
$this->processUpdatesBatch($handlers, $timeout, $marker); $this->processUpdatesBatch($handlers, $timeout, $marker);
} catch (NetworkException $e) { } catch (NetworkException $e) {
error_log("Network error: " . $e->getMessage()); $this->logger->error(
'Long-polling network error: {message}',
['message' => $e->getMessage(), 'exception' => $e],
);
sleep(5); sleep(5);
} catch (\Exception $e) { } 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); sleep(1);
} }
} }
+24
View File
@@ -19,6 +19,8 @@ use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface; 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. * 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 StreamFactoryInterface $streamFactory A PSR-17 factory for creating request body streams.
* @param string $baseUrl The base URL for API requests. * @param string $baseUrl The base URL for API requests.
* @param string|null $apiVersion The API version to use for requests. * @param string|null $apiVersion The API version to use for requests.
* @param LoggerInterface $logger
* *
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
@@ -44,6 +47,7 @@ final readonly class Client implements ClientApiInterface
private StreamFactoryInterface $streamFactory, private StreamFactoryInterface $streamFactory,
private string $baseUrl, private string $baseUrl,
private ?string $apiVersion = null, private ?string $apiVersion = null,
private LoggerInterface $logger = new NullLogger(),
) { ) {
if (empty($accessToken)) { if (empty($accessToken)) {
throw new InvalidArgumentException('Access token cannot be empty.'); throw new InvalidArgumentException('Access token cannot be empty.');
@@ -60,6 +64,12 @@ final readonly class Client implements ClientApiInterface
$queryParams['v'] = $this->apiVersion; $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); $fullUrl = $this->baseUrl . $uri . '?' . http_build_query($queryParams);
$request = $this->requestFactory->createRequest($method, $fullUrl); $request = $this->requestFactory->createRequest($method, $fullUrl);
@@ -79,6 +89,10 @@ final readonly class Client implements ClientApiInterface
$response = $this->httpClient->sendRequest($request); $response = $this->httpClient->sendRequest($request);
} catch (ClientExceptionInterface $e) { } catch (ClientExceptionInterface $e) {
// This catches network errors, DNS failures, timeouts, etc. // 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); throw new NetworkException($e->getMessage(), $e->getCode(), $e);
} }
@@ -86,6 +100,11 @@ final readonly class Client implements ClientApiInterface
$responseBody = (string)$response->getBody(); $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) // Handle successful but empty responses (e.g., from DELETE endpoints)
if (empty($responseBody)) { if (empty($responseBody)) {
// The API spec often returns {"success": true}, so we can simulate that // 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'; $errorCode = $data['code'] ?? 'unknown';
$errorMessage = $data['message'] ?? 'An unknown error occurred.'; $errorMessage = $data['message'] ?? 'An unknown error occurred.';
$this->logger->error('API error response received', [
'status' => $statusCode,
'body' => $responseBody,
]);
throw match ($statusCode) { throw match ($statusCode) {
401 => new UnauthorizedException($errorMessage, $errorCode, $response), 401 => new UnauthorizedException($errorMessage, $errorCode, $response),
403 => new ForbiddenException($errorMessage, $errorCode, $response), 403 => new ForbiddenException($errorMessage, $errorCode, $response),
+1 -1
View File
@@ -26,7 +26,7 @@ interface ClientApiInterface
public function request(string $method, string $uri, array $queryParams = [], array $body = []): array; 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 string $uri URL received from the download API.
* @param resource|string $fileContents File content (stream resource or string). * @param resource|string $fileContents File content (stream resource or string).
+4 -2
View File
@@ -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\CallbackButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\ChatButton; use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\ChatButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\LinkButton; 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\RequestContactButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestGeoLocationButton; use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Inline\RequestGeoLocationButton;
use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton; use BushlanovDev\MaxMessengerBot\Models\Attachments\Buttons\Reply\AbstractReplyButton;
@@ -224,7 +225,7 @@ class ModelFactory
AttachmentType::InlineKeyboard => InlineKeyboardAttachment::fromArray($data), AttachmentType::InlineKeyboard => InlineKeyboardAttachment::fromArray($data),
AttachmentType::ReplyKeyboard => ReplyKeyboardAttachment::fromArray($data), AttachmentType::ReplyKeyboard => ReplyKeyboardAttachment::fromArray($data),
AttachmentType::Location => LocationAttachment::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')),
}; };
} }
@@ -265,7 +266,8 @@ class ModelFactory
InlineButtonType::RequestContact => RequestContactButton::fromArray($data), InlineButtonType::RequestContact => RequestContactButton::fromArray($data),
InlineButtonType::RequestGeoLocation => RequestGeoLocationButton::fromArray($data), InlineButtonType::RequestGeoLocation => RequestGeoLocationButton::fromArray($data),
InlineButtonType::Chat => ChatButton::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')),
}; };
} }
+2 -3
View File
@@ -9,8 +9,7 @@ final readonly class Image extends AbstractModel
/** /**
* @param string $url URL of image. * @param string $url URL of image.
*/ */
public function __construct( public function __construct(public string $url)
public string $url, {
) {
} }
} }
+8
View File
@@ -9,6 +9,8 @@ use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException; use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate; use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
use Psr\Http\Message\ServerRequestInterface; 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. * 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 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 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 string|null $secret The secret key provided during webhook subscription to verify requests.
* @param LoggerInterface $logger A PSR-3 compatible logger.
*/ */
public function __construct( public function __construct(
private readonly Api $api, private readonly Api $api,
private readonly ModelFactory $modelFactory, private readonly ModelFactory $modelFactory,
private readonly ?string $secret = null, private readonly ?string $secret = null,
private readonly LoggerInterface $logger = new NullLogger(),
) { ) {
} }
@@ -254,6 +258,8 @@ final class WebhookHandler
$payload = (string)$request->getBody(); $payload = (string)$request->getBody();
$signature = $request->getHeaderLine('X-Max-Bot-Api-Secret'); $signature = $request->getHeaderLine('X-Max-Bot-Api-Secret');
$this->logger->debug('Received webhook payload', ['body' => $payload]);
if (empty($payload)) { if (empty($payload)) {
throw new SerializationException('Webhook body is empty.'); throw new SerializationException('Webhook body is empty.');
} }
@@ -263,6 +269,7 @@ final class WebhookHandler
try { try {
$data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR); $data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) { } 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); 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)) { if (!hash_equals($this->secret, $signature)) {
$this->logger->warning('Webhook signature verification failed', ['received_signature' => $signature]);
throw new SecurityException('Signature verification failed.'); throw new SecurityException('Signature verification failed.');
} }
} }
+11 -7
View File
@@ -70,6 +70,7 @@ use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\Exception;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use ReflectionClass; use ReflectionClass;
use RuntimeException; use RuntimeException;
@@ -122,6 +123,8 @@ final class ApiTest extends TestCase
private MockObject&ClientApiInterface $clientMock; private MockObject&ClientApiInterface $clientMock;
private MockObject&ModelFactory $modelFactoryMock; private MockObject&ModelFactory $modelFactoryMock;
private MockObject&LoggerInterface $loggerMock;
private Api $api; private Api $api;
/** /**
@@ -133,8 +136,9 @@ final class ApiTest extends TestCase
$this->clientMock = $this->createMock(ClientApiInterface::class); $this->clientMock = $this->createMock(ClientApiInterface::class);
$this->modelFactoryMock = $this->createMock(ModelFactory::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] #[Test]
@@ -773,10 +777,12 @@ final class ApiTest extends TestCase
$handlers = [UpdateType::MessageCreated->value => fn() => null]; $handlers = [UpdateType::MessageCreated->value => fn() => null];
$apiMock = $this->getMockBuilder(Api::class) $apiMock = $this->getMockBuilder(Api::class)
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock]) ->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock, $this->loggerMock])
->onlyMethods(['processUpdatesBatch']) ->onlyMethods(['processUpdatesBatch'])
->getMock(); ->getMock();
$this->loggerMock->expects($this->once())->method('error');
$apiMock->expects($this->any()) $apiMock->expects($this->any())
->method('processUpdatesBatch') ->method('processUpdatesBatch')
->willReturnCallback(function () { ->willReturnCallback(function () {
@@ -790,8 +796,6 @@ final class ApiTest extends TestCase
} }
}); });
$this->expectOutputRegex('/Network error: Simulated network error/');
try { try {
$apiMock->handleUpdates($handlers); $apiMock->handleUpdates($handlers);
} catch (\Error $e) { } catch (\Error $e) {
@@ -828,10 +832,12 @@ final class ApiTest extends TestCase
$handlers = [UpdateType::MessageCreated->value => fn() => null]; $handlers = [UpdateType::MessageCreated->value => fn() => null];
$apiMock = $this->getMockBuilder(Api::class) $apiMock = $this->getMockBuilder(Api::class)
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock]) ->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock, $this->loggerMock])
->onlyMethods(['processUpdatesBatch']) ->onlyMethods(['processUpdatesBatch'])
->getMock(); ->getMock();
$this->loggerMock->expects($this->once())->method('error');
$apiMock->expects($this->any()) $apiMock->expects($this->any())
->method('processUpdatesBatch') ->method('processUpdatesBatch')
->willReturnCallback(function () { ->willReturnCallback(function () {
@@ -847,8 +853,6 @@ final class ApiTest extends TestCase
} }
}); });
$this->expectOutputRegex('/An error occurred: Simulated JSON error/');
try { try {
$apiMock->handleUpdates($handlers); $apiMock->handleUpdates($handlers);
} catch (\Error $e) { } catch (\Error $e) {
+33
View File
@@ -28,6 +28,7 @@ use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface; use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface;
#[CoversClass(Client::class)] #[CoversClass(Client::class)]
final class ClientTest extends TestCase final class ClientTest extends TestCase
@@ -42,6 +43,7 @@ final class ClientTest extends TestCase
private MockObject&RequestInterface $requestMock; private MockObject&RequestInterface $requestMock;
private MockObject&ResponseInterface $responseMock; private MockObject&ResponseInterface $responseMock;
private MockObject&StreamInterface $streamMock; private MockObject&StreamInterface $streamMock;
private MockObject&LoggerInterface $loggerMock;
private Client $client; private Client $client;
@@ -61,6 +63,7 @@ final class ClientTest extends TestCase
$this->requestMock = $this->createMock(RequestInterface::class); $this->requestMock = $this->createMock(RequestInterface::class);
$this->responseMock = $this->createMock(ResponseInterface::class); $this->responseMock = $this->createMock(ResponseInterface::class);
$this->streamMock = $this->createMock(StreamInterface::class); $this->streamMock = $this->createMock(StreamInterface::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
// Common mock setups // Common mock setups
$this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock); $this->requestFactoryMock->method('createRequest')->willReturn($this->requestMock);
@@ -75,6 +78,7 @@ final class ClientTest extends TestCase
$this->streamFactory, $this->streamFactory,
self::API_BASE_URL, self::API_BASE_URL,
self::API_VERSION, self::API_VERSION,
$this->loggerMock,
); );
} }
@@ -376,4 +380,33 @@ final class ClientTest extends TestCase
$this->streamMock->method('__toString')->willReturn('{not-a-valid-json'); $this->streamMock->method('__toString')->willReturn('{not-a-valid-json');
$this->client->upload('http://some.url', 'content', 'file.txt'); $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); $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);
}
} }
+21
View File
@@ -25,6 +25,7 @@ use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
#[CoversClass(WebhookHandler::class)] #[CoversClass(WebhookHandler::class)]
#[UsesClass(Message::class)] #[UsesClass(Message::class)]
@@ -38,6 +39,8 @@ final class WebhookHandlerTest extends TestCase
private MockObject&Api $apiMock; private MockObject&Api $apiMock;
private MockObject&ModelFactory $modelFactoryMock; private MockObject&ModelFactory $modelFactoryMock;
private MockObject&LoggerInterface $loggerMock;
private const string SECRET = 'my-super-secret-key'; private const string SECRET = 'my-super-secret-key';
protected function setUp(): void protected function setUp(): void
@@ -45,6 +48,7 @@ final class WebhookHandlerTest extends TestCase
parent::setUp(); parent::setUp();
$this->apiMock = $this->createMock(Api::class); $this->apiMock = $this->createMock(Api::class);
$this->modelFactoryMock = $this->createMock(ModelFactory::class); $this->modelFactoryMock = $this->createMock(ModelFactory::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
} }
private function createValidUpdatePayload(): string private function createValidUpdatePayload(): string
@@ -261,4 +265,21 @@ final class WebhookHandlerTest extends TestCase
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock); $webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
$webhookHandler->handle(null); $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);
}
} }