mirror of
https://github.com/BushlanovDev/max-bot-api-client-php.git
synced 2026-08-19 00:22:51 +00:00
Refactoring update dispatchers
This commit is contained in:
+32
-110
@@ -10,7 +10,6 @@ use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UploadType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\ClientApiException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\Models\AbstractModel;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Attachments\Requests\AbstractAttachmentRequest;
|
||||
@@ -31,12 +30,10 @@ use BushlanovDev\MaxMessengerBot\Models\MessageLink;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Result;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Subscription;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
|
||||
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use ReflectionException;
|
||||
@@ -83,13 +80,16 @@ class Api
|
||||
|
||||
private readonly LoggerInterface $logger;
|
||||
|
||||
private readonly UpdateDispatcher $updateDispatcher;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param ModelFactory|null $modelFactory The model factory.
|
||||
* @param LoggerInterface|null $logger PSR LoggerInterface.
|
||||
* @param UpdateDispatcher|null $updateDispatcher The update dispatcher.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
@@ -98,6 +98,7 @@ class Api
|
||||
?ClientApiInterface $client = null,
|
||||
?ModelFactory $modelFactory = null,
|
||||
?LoggerInterface $logger = null,
|
||||
?UpdateDispatcher $updateDispatcher = null,
|
||||
) {
|
||||
$this->logger = $logger ?? new NullLogger();
|
||||
|
||||
@@ -129,6 +130,7 @@ class Api
|
||||
|
||||
$this->client = $client;
|
||||
$this->modelFactory = $modelFactory ?? new ModelFactory();
|
||||
$this->updateDispatcher = $updateDispatcher ?? new UpdateDispatcher($this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,68 +152,46 @@ class Api
|
||||
return $this->client->request($method, $uri, $queryParams, $body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the central update dispatcher instance. Use this to register your event and command handlers.
|
||||
*
|
||||
* @return UpdateDispatcher
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function getUpdateDispatcher(): UpdateDispatcher
|
||||
{
|
||||
return $this->updateDispatcher;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebhookHandler instance, pre-configured with the necessary dependencies.
|
||||
*
|
||||
* @param string|null $secret The secret key for request verification.
|
||||
* Should be the same one you used when calling the subscribe() method.
|
||||
*
|
||||
* @return WebhookHandler
|
||||
*/
|
||||
public function createWebhookHandler(?string $secret = null): WebhookHandler
|
||||
{
|
||||
return new WebhookHandler($this, $this->modelFactory, $secret, $this->logger);
|
||||
return new WebhookHandler(
|
||||
$this->updateDispatcher,
|
||||
$this->modelFactory,
|
||||
$this->logger,
|
||||
$secret,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an incoming webhook request and returns a single Update object.
|
||||
* This is an alternative to the event-driven WebhookHandler::handle() method,
|
||||
* allowing for manual processing of updates.
|
||||
* Creates a LongPollingHandler instance, pre-configured for running a long-polling loop.
|
||||
*
|
||||
* @param string|null $secret The secret key to verify the request signature.
|
||||
* @param ServerRequestInterface|null $request The PSR-7 request object. If null, it's created from globals.
|
||||
*
|
||||
* @return AbstractUpdate The parsed update object (e.g., MessageCreatedUpdate).
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws \LogicException
|
||||
* @return LongPollingHandler
|
||||
*/
|
||||
public function getWebhookUpdate(?string $secret = null, ?ServerRequestInterface $request = null): AbstractUpdate
|
||||
public function createLongPollingHandler(): LongPollingHandler
|
||||
{
|
||||
return $this->createWebhookHandler($secret)->getUpdate($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple way to process a single incoming webhook request using callbacks.
|
||||
* This method creates a WebhookHandler, registers the provided callbacks, and processes the request.
|
||||
*
|
||||
* @param array<string, callable> $handlers An associative array where keys are UpdateType string values
|
||||
* (e.g., UpdateType::MessageCreated->value) and values are handlers.
|
||||
* @param string|null $secret The secret key for request verification.
|
||||
* @param ServerRequestInterface|null $request The PSR-7 request object.
|
||||
*
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws ReflectionException
|
||||
* @throws LogicException
|
||||
*/
|
||||
public function handleWebhooks(
|
||||
array $handlers,
|
||||
?string $secret = null,
|
||||
?ServerRequestInterface $request = null,
|
||||
): void {
|
||||
$webhookHandler = $this->createWebhookHandler($secret);
|
||||
|
||||
foreach ($handlers as $updateType => $callback) {
|
||||
$updateType = UpdateType::tryFrom($updateType);
|
||||
// @phpstan-ignore-next-line
|
||||
if ($updateType && is_callable($callback)) {
|
||||
$webhookHandler->addHandler($updateType, $callback);
|
||||
}
|
||||
}
|
||||
|
||||
$webhookHandler->handle($request);
|
||||
return new LongPollingHandler(
|
||||
$this,
|
||||
$this->updateDispatcher,
|
||||
$this->logger,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,64 +231,6 @@ class Api
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a long-polling loop to process updates using callbacks.
|
||||
* This method will run indefinitely until the script is terminated.
|
||||
*
|
||||
* @param array<string, callable> $handlers An associative array where keys are UpdateType enums
|
||||
* and values are the corresponding handler functions.
|
||||
* @param int|null $timeout Timeout in seconds for long polling (0-90). Defaults to 90.
|
||||
* @param int|null $marker Pass `null` to get updates you didn't get yet.
|
||||
*/
|
||||
public function handleUpdates(array $handlers, ?int $timeout = null, ?int $marker = null): void
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
while (true) {
|
||||
try {
|
||||
$this->processUpdatesBatch($handlers, $timeout, $marker);
|
||||
} catch (NetworkException $e) {
|
||||
$this->logger->error(
|
||||
'Long-polling network error: {message}',
|
||||
['message' => $e->getMessage(), 'exception' => $e],
|
||||
);
|
||||
sleep(5);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error(
|
||||
'An error occurred during long-polling: {message}',
|
||||
['message' => $e->getMessage(), 'exception' => $e],
|
||||
);
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single batch of updates. This is the core logic used by handleUpdates().
|
||||
* Useful for custom loop implementations or for testing.
|
||||
*
|
||||
* @param array<string, callable> $handlers An associative array of update handlers.
|
||||
* @param int|null $timeout Timeout for the getUpdates call.
|
||||
* @param int|null $marker The marker for which updates to fetch.
|
||||
*
|
||||
* @throws ClientApiException
|
||||
* @throws NetworkException
|
||||
* @throws ReflectionException
|
||||
* @throws SerializationException
|
||||
*/
|
||||
public function processUpdatesBatch(array $handlers, ?int $timeout, ?int &$marker = null): void
|
||||
{
|
||||
$updateList = $this->getUpdates(timeout: $timeout, marker: $marker);
|
||||
|
||||
foreach ($updateList->updates as $update) {
|
||||
$handler = $handlers[$update->updateType->value] ?? null;
|
||||
if ($handler) {
|
||||
$handler($update, $this);
|
||||
}
|
||||
}
|
||||
|
||||
$marker = $updateList->marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information about the current bot, identified by an access token.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Handles receiving updates via long polling.
|
||||
*/
|
||||
final readonly class LongPollingHandler
|
||||
{
|
||||
/**
|
||||
* @param Api $api
|
||||
* @param UpdateDispatcher $dispatcher The update dispatcher.
|
||||
* @param LoggerInterface $logger PSR LoggerInterface.
|
||||
*/
|
||||
public function __construct(
|
||||
private Api $api,
|
||||
private UpdateDispatcher $dispatcher,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single batch of updates. Useful for custom loop implementations or for testing.
|
||||
*
|
||||
* @param int $timeout Timeout for the getUpdates call.
|
||||
* @param int|null $marker The marker for which updates to fetch.
|
||||
* @return int|null The new marker to be used for the next iteration.
|
||||
* @throws \Exception Re-throws exceptions from the API or dispatcher.
|
||||
*/
|
||||
public function processSingleBatch(int $timeout, ?int $marker): ?int
|
||||
{
|
||||
$updateList = $this->api->getUpdates(timeout: $timeout, marker: $marker);
|
||||
|
||||
foreach ($updateList->updates as $update) {
|
||||
$this->dispatcher->dispatch($update);
|
||||
}
|
||||
|
||||
return $updateList->marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a long-polling loop to process updates.
|
||||
* This method will run indefinitely until the script is terminated.
|
||||
*
|
||||
* @param int $timeout Timeout in seconds for long polling (0-90).
|
||||
* @param int|null $marker Initial marker. Pass `null` to get updates you didn't get yet.
|
||||
*/
|
||||
public function handle(int $timeout = 90, ?int $marker = null): void
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
while (true) {
|
||||
try {
|
||||
$marker = $this->processSingleBatch($timeout, $marker);
|
||||
} catch (NetworkException $e) {
|
||||
$this->logger->error(
|
||||
'Long-polling network error: {message}',
|
||||
['message' => $e->getMessage(), 'exception' => $e],
|
||||
);
|
||||
sleep(5);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error(
|
||||
'An error occurred during long-polling: {message}',
|
||||
['message' => $e->getMessage(), 'exception' => $e],
|
||||
);
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
|
||||
/**
|
||||
* Dispatches updates to registered handlers. Supports handling specific update types and text commands.
|
||||
*/
|
||||
final class UpdateDispatcher
|
||||
{
|
||||
/**
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
private array $handlers = [];
|
||||
|
||||
/**
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
private array $commandHandlers = [];
|
||||
|
||||
/**
|
||||
* @param Api $api
|
||||
*/
|
||||
public function __construct(private readonly Api $api)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for a specific update type.
|
||||
*
|
||||
* @param UpdateType $type The type of update to handle.
|
||||
* @param callable $handler The function to execute when the update is received.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function addHandler(UpdateType $type, callable $handler): self
|
||||
{
|
||||
$this->handlers[$type->value] = $handler;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for a text command without a command prefix "/" (e.g., "start").
|
||||
* The command must be the first word in a message.
|
||||
*
|
||||
* @param string $command The command string (e.g., "start").
|
||||
* @param callable(MessageCreatedUpdate, Api): void $handler The handler to execute.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function onCommand(string $command, callable $handler): self
|
||||
{
|
||||
$this->commandHandlers[$command] = $handler;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a parsed Update object to its registered handler.
|
||||
* Command handlers are prioritized over generic message handlers.
|
||||
*
|
||||
* @param AbstractUpdate $update The update object to dispatch.
|
||||
*/
|
||||
public function dispatch(AbstractUpdate $update): void
|
||||
{
|
||||
if ($update instanceof MessageCreatedUpdate && $update->message->body?->text) {
|
||||
$text = $update->message->body->text;
|
||||
$parts = explode(' ', trim($text));
|
||||
$command = $parts[0];
|
||||
|
||||
if (isset($this->commandHandlers[$command])) {
|
||||
$this->commandHandlers[$command]($update, $this->api);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$handler = $this->handlers[$update->updateType->value] ?? null;
|
||||
if ($handler) {
|
||||
$handler($update, $this->api);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageCreated, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageCreatedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageCreated(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageCreated, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageCallback, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageCallbackUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageCallback(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageCallback, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageEdited, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageEditedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageEdited(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageEdited, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageRemovedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotAdded, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotAddedToChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotAdded(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotAdded, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotRemovedFromChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::UserAdded, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\UserAddedToChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserAdded(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::UserAdded, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::UserRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\UserRemovedFromChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::UserRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotStarted, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotStartedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotStarted(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotStarted, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::ChatTitleChanged, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\ChatTitleChangedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onChatTitleChanged(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::ChatTitleChanged, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageChatCreated, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageChatCreatedUpdate, Api): void $handler
|
||||
*
|
||||
* @return $this
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageChatCreated(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageChatCreated, $handler);
|
||||
}
|
||||
}
|
||||
+18
-230
@@ -4,205 +4,37 @@ declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
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.
|
||||
* It verifies the request's authenticity, parses it, and dispatches it
|
||||
* to the appropriate registered event handler.
|
||||
* It verifies the request's authenticity, parses it, and uses an UpdateDispatcher
|
||||
* to route it to the appropriate handler.
|
||||
*/
|
||||
final class WebhookHandler
|
||||
final readonly class WebhookHandler
|
||||
{
|
||||
/**
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
private array $handlers = [];
|
||||
|
||||
/**
|
||||
* @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.
|
||||
* @param UpdateDispatcher $dispatcher The update dispatcher.
|
||||
* @param ModelFactory $modelFactory The model factory.
|
||||
* @param LoggerInterface $logger PSR LoggerInterface.
|
||||
* @param string|null $secret The secret key for request verification.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Api $api,
|
||||
private readonly ModelFactory $modelFactory,
|
||||
private readonly ?string $secret = null,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
private UpdateDispatcher $dispatcher,
|
||||
private ModelFactory $modelFactory,
|
||||
private LoggerInterface $logger,
|
||||
private ?string $secret,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a handler for a specific update type.
|
||||
*
|
||||
* @param UpdateType $type The type of update to handle.
|
||||
* @param callable $handler The function to execute when the update is received.
|
||||
* The handler will receive the specific Update object (e.g., MessageCreatedUpdate) and the Api instance.
|
||||
*
|
||||
* @return WebhookHandler
|
||||
*/
|
||||
public function addHandler(UpdateType $type, callable $handler): self
|
||||
{
|
||||
$this->handlers[$type->value] = $handler;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageCreated, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageCreatedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
*/
|
||||
public function onMessageCreated(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageCreated, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageCallback, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageCallbackUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageCallback(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageCallback, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageEdited, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageEditedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageEdited(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageEdited, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageRemovedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotAdded, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotAddedToChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotAdded(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotAdded, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotRemovedFromChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::UserAdded, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\UserAddedToChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserAdded(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::UserAdded, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::UserRemoved, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\UserRemovedFromChatUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onUserRemoved(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::UserRemoved, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::BotStarted, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\BotStartedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onBotStarted(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::BotStarted, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::ChatTitleChanged, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\ChatTitleChangedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onChatTitleChanged(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::ChatTitleChanged, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenient alias for addHandler(UpdateType::MessageChatCreated, $handler).
|
||||
*
|
||||
* @param callable(Models\Updates\MessageChatCreatedUpdate, Api): void $handler
|
||||
*
|
||||
* @return WebhookHandler
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function onMessageChatCreated(callable $handler): self
|
||||
{
|
||||
return $this->addHandler(UpdateType::MessageChatCreated, $handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an incoming webhook request.
|
||||
* This is the main entry point. It reads the HTTP request body and headers,
|
||||
* verifies the signature, parses the update, and calls the appropriate handler.
|
||||
* It automatically sends the correct HTTP response code.
|
||||
* It reads the HTTP request, verifies, parses, and dispatches the update.
|
||||
*
|
||||
* @param ServerRequestInterface|null $request The Psr7 HTTP request to process.
|
||||
* @param ServerRequestInterface|null $request The PSR-7 HTTP request. If null, created from globals.
|
||||
*
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
@@ -210,24 +42,6 @@ final class WebhookHandler
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public function handle(?ServerRequestInterface $request = null): void
|
||||
{
|
||||
$this->dispatch($this->getUpdate($request));
|
||||
|
||||
http_response_code(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw request data and returns a typed Update object.
|
||||
*
|
||||
* @param ServerRequestInterface|null $request The Psr7 HTTP request to process.
|
||||
*
|
||||
* @return AbstractUpdate
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public function getUpdate(?ServerRequestInterface $request = null): AbstractUpdate
|
||||
{
|
||||
if ($request === null) {
|
||||
if (!class_exists(\GuzzleHttp\Psr7\ServerRequest::class)) {
|
||||
@@ -239,32 +53,14 @@ final class WebhookHandler
|
||||
$request = \GuzzleHttp\Psr7\ServerRequest::fromGlobals();
|
||||
}
|
||||
|
||||
return $this->parseUpdate($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw request data and returns a typed Update object.
|
||||
*
|
||||
* @param ServerRequestInterface $request
|
||||
*
|
||||
* @return AbstractUpdate
|
||||
* @throws \ReflectionException
|
||||
* @throws SecurityException
|
||||
* @throws SerializationException
|
||||
* @throws \LogicException
|
||||
*/
|
||||
public function parseUpdate(ServerRequestInterface $request): AbstractUpdate
|
||||
{
|
||||
$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.');
|
||||
}
|
||||
|
||||
$this->verifySignature($signature);
|
||||
$this->verifySignature($request->getHeaderLine('X-Max-Bot-Api-Secret'));
|
||||
|
||||
try {
|
||||
$data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
|
||||
@@ -273,20 +69,12 @@ final class WebhookHandler
|
||||
throw new SerializationException('Failed to decode webhook body as JSON.', 0, $e);
|
||||
}
|
||||
|
||||
return $this->modelFactory->createUpdate($data);
|
||||
}
|
||||
$update = $this->modelFactory->createUpdate($data);
|
||||
|
||||
/**
|
||||
* Dispatches a parsed Update object to its registered handler.
|
||||
*
|
||||
* @param AbstractUpdate $update
|
||||
*/
|
||||
public function dispatch(AbstractUpdate $update): void
|
||||
{
|
||||
$handler = $this->handlers[$update->updateType->value] ?? null;
|
||||
$this->dispatcher->dispatch($update);
|
||||
|
||||
if ($handler) {
|
||||
$handler($update, $this->api);
|
||||
if (!headers_sent()) {
|
||||
http_response_code(200);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\ClientApiInterface;
|
||||
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
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 Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
|
||||
#[CoversClass(Api::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(WebhookHandler::class)]
|
||||
#[UsesClass(LongPollingHandler::class)]
|
||||
final class ApiFactoryMethodsTest extends TestCase
|
||||
{
|
||||
private MockObject&ClientApiInterface $clientMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
private Api $api;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clientMock = $this->createMock(ClientApiInterface::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$apiForDispatcher = $this->createMock(Api::class);
|
||||
$dispatcher = new UpdateDispatcher($apiForDispatcher);
|
||||
|
||||
$this->api = new Api(
|
||||
'fake-token',
|
||||
$this->clientMock,
|
||||
$this->modelFactoryMock,
|
||||
$this->loggerMock,
|
||||
$dispatcher,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createWebhookHandlerReturnsCorrectlyConfiguredInstance(): void
|
||||
{
|
||||
$secret = 'my-test-secret';
|
||||
|
||||
$webhookHandler = $this->api->createWebhookHandler($secret);
|
||||
|
||||
$this->assertInstanceOf(WebhookHandler::class, $webhookHandler);
|
||||
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'updateDispatcher'),
|
||||
$this->getPrivateProperty($webhookHandler, 'dispatcher'),
|
||||
);
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'modelFactory'),
|
||||
$this->getPrivateProperty($webhookHandler, 'modelFactory'),
|
||||
);
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'logger'),
|
||||
$this->getPrivateProperty($webhookHandler, 'logger'),
|
||||
);
|
||||
$this->assertSame(
|
||||
$secret,
|
||||
$this->getPrivateProperty($webhookHandler, 'secret'),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createLongPollingHandlerReturnsCorrectlyConfiguredInstance(): void
|
||||
{
|
||||
$longPollingHandler = $this->api->createLongPollingHandler();
|
||||
|
||||
$this->assertInstanceOf(LongPollingHandler::class, $longPollingHandler);
|
||||
|
||||
$this->assertSame(
|
||||
$this->api,
|
||||
$this->getPrivateProperty($longPollingHandler, 'api')
|
||||
);
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'updateDispatcher'),
|
||||
$this->getPrivateProperty($longPollingHandler, 'dispatcher')
|
||||
);
|
||||
$this->assertSame(
|
||||
$this->getPrivateProperty($this->api, 'logger'),
|
||||
$this->getPrivateProperty($longPollingHandler, 'logger')
|
||||
);
|
||||
}
|
||||
|
||||
private function getPrivateProperty(object $object, string $propertyName): mixed
|
||||
{
|
||||
$reflection = new ReflectionClass($object);
|
||||
$property = $reflection->getProperty($propertyName);
|
||||
|
||||
return $property->getValue($object);
|
||||
}
|
||||
}
|
||||
+2
-236
@@ -56,8 +56,8 @@ use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint;
|
||||
use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails;
|
||||
use BushlanovDev\MaxMessengerBot\Models\VideoUrls;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use org\bovigo\vfs\vfsStream;
|
||||
@@ -117,6 +117,7 @@ use RuntimeException;
|
||||
#[UsesClass(ChatPatch::class)]
|
||||
#[UsesClass(VideoAttachmentDetails::class)]
|
||||
#[UsesClass(VideoUrls::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
final class ApiTest extends TestCase
|
||||
{
|
||||
use PHPMock;
|
||||
@@ -613,201 +614,6 @@ final class ApiTest extends TestCase
|
||||
$this->api->getUpdates();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createWebhookHandlerReturnsInstanceWithProvidedSecret(): void
|
||||
{
|
||||
$secret = 'my-test-secret-key';
|
||||
$webhookHandler = $this->api->createWebhookHandler($secret);
|
||||
|
||||
$this->assertInstanceOf(WebhookHandler::class, $webhookHandler);
|
||||
|
||||
$reflection = new ReflectionClass($webhookHandler);
|
||||
|
||||
$apiProperty = $reflection->getProperty('api');
|
||||
$this->assertSame($this->api, $apiProperty->getValue($webhookHandler));
|
||||
|
||||
$factoryProperty = $reflection->getProperty('modelFactory');
|
||||
$this->assertSame($this->modelFactoryMock, $factoryProperty->getValue($webhookHandler));
|
||||
|
||||
$secretProperty = $reflection->getProperty('secret');
|
||||
$this->assertSame($secret, $secretProperty->getValue($webhookHandler));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getWebhookUpdateCreatesHandlerAndReturnsUpdate(): void
|
||||
{
|
||||
$api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock);
|
||||
|
||||
$payload = '{"update_type":"bot_started","timestamp":123,"chat_id":1,"user":{"user_id":1,"first_name":"Test","is_bot":false,"last_activity_time":123}}';
|
||||
$request = new ServerRequest('POST', '/webhook', [], $payload);
|
||||
|
||||
$expectedUpdate = new BotStartedUpdate(
|
||||
123,
|
||||
1,
|
||||
new User(1, 'Test', null, null, false, 123, null, null, null),
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with(json_decode($payload, true))
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$result = $api->getWebhookUpdate(null, $request);
|
||||
|
||||
$this->assertSame($expectedUpdate, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleWebhooksDispatchesCorrectHandler(): void
|
||||
{
|
||||
$api = new Api('fake-token', $this->clientMock, $this->modelFactoryMock);
|
||||
$secret = 'my-secret';
|
||||
|
||||
$payload = '{"update_type":"message_created","timestamp":123,"message":{"timestamp":1,"body":{"mid":"m1","seq":1},"recipient":{"chat_type":"dialog"}}}';
|
||||
$request = new \GuzzleHttp\Psr7\ServerRequest(
|
||||
'POST',
|
||||
'/webhook',
|
||||
['X-Max-Bot-Api-Secret' => $secret],
|
||||
$payload,
|
||||
);
|
||||
|
||||
$expectedUpdate = new MessageCreatedUpdate(
|
||||
123,
|
||||
Message::fromArray(
|
||||
['timestamp' => 1, 'body' => ['mid' => 'm1', 'seq' => 1], 'recipient' => ['chat_type' => 'dialog']]
|
||||
),
|
||||
null,
|
||||
);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with(json_decode($payload, true))
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$messageHandlerCallCount = 0;
|
||||
$messageHandlerCapturedUpdate = null;
|
||||
|
||||
$messageHandler = function (MessageCreatedUpdate $update, Api $receivedApi) use (
|
||||
&$messageHandlerCallCount,
|
||||
&$messageHandlerCapturedUpdate,
|
||||
) {
|
||||
$messageHandlerCallCount++;
|
||||
$messageHandlerCapturedUpdate = $update;
|
||||
};
|
||||
|
||||
$botStartedHandlerCallCount = 0;
|
||||
$botStartedHandler = function () use (&$botStartedHandlerCallCount) {
|
||||
$botStartedHandlerCallCount++;
|
||||
};
|
||||
|
||||
$handlers = [
|
||||
UpdateType::MessageCreated->value => $messageHandler,
|
||||
UpdateType::BotStarted->value => $botStartedHandler,
|
||||
];
|
||||
|
||||
$api->handleWebhooks($handlers, $secret, $request);
|
||||
|
||||
$this->assertSame(1, $messageHandlerCallCount, 'MessageCreated handler should be called once.');
|
||||
$this->assertSame(0, $botStartedHandlerCallCount, 'BotStarted handler should not be called.');
|
||||
$this->assertSame($expectedUpdate, $messageHandlerCapturedUpdate);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function processUpdatesBatchDispatchesHandlersAndUpdatesMarker(): void
|
||||
{
|
||||
$messageUpdate = new MessageCreatedUpdate(
|
||||
1,
|
||||
Message::fromArray(
|
||||
['timestamp' => 1, 'body' => ['mid' => 'm1', 'seq' => 1], 'recipient' => ['chat_type' => 'dialog']]
|
||||
),
|
||||
null,
|
||||
);
|
||||
$botStartedUpdate = new BotStartedUpdate(
|
||||
2, 123,
|
||||
User::fromArray(['user_id' => 1, 'first_name' => 'Test', 'is_bot' => false, 'last_activity_time' => 1]),
|
||||
null,
|
||||
null,
|
||||
);
|
||||
|
||||
$messageHandlerCallCount = 0;
|
||||
$botStartedHandlerCallCount = 0;
|
||||
$handlers = [
|
||||
UpdateType::MessageCreated->value => function () use (&$messageHandlerCallCount) {
|
||||
$messageHandlerCallCount++;
|
||||
},
|
||||
UpdateType::BotStarted->value => function () use (&$botStartedHandlerCallCount) {
|
||||
$botStartedHandlerCallCount++;
|
||||
},
|
||||
];
|
||||
|
||||
$apiMock = $this->getMockBuilder(Api::class)
|
||||
->setConstructorArgs(['fake-token', $this->clientMock, $this->modelFactoryMock])
|
||||
->onlyMethods(['getUpdates'])
|
||||
->getMock();
|
||||
|
||||
$apiMock->expects($this->once())
|
||||
->method('getUpdates')
|
||||
->willReturn(new UpdateList([$messageUpdate, $botStartedUpdate], 12345));
|
||||
|
||||
$marker = null;
|
||||
|
||||
$apiMock->processUpdatesBatch($handlers, 90, $marker);
|
||||
|
||||
$this->assertSame(1, $messageHandlerCallCount, 'MessageCreated handler should have been called once.');
|
||||
$this->assertSame(1, $botStartedHandlerCallCount, 'BotStarted handler should have been called once.');
|
||||
$this->assertSame(12345, $marker, 'Marker should have been updated to the new value.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @var int Counter for processUpdatesBatch method calls.
|
||||
*/
|
||||
private int $processUpdatesBatchCallCount = 0;
|
||||
|
||||
/**
|
||||
* @throws \Throwable We catch a base \Error, so we need to declare it here.
|
||||
*/
|
||||
#[Test]
|
||||
public function handleUpdatesLoopContinuesAfterException(): void
|
||||
{
|
||||
$this->processUpdatesBatchCallCount = 0;
|
||||
$handlers = [UpdateType::MessageCreated->value => fn() => null];
|
||||
|
||||
$apiMock = $this->getMockBuilder(Api::class)
|
||||
->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 () {
|
||||
switch ($this->processUpdatesBatchCallCount++) {
|
||||
case 0:
|
||||
return;
|
||||
case 1:
|
||||
throw new \BushlanovDev\MaxMessengerBot\Exceptions\NetworkException("Simulated network error");
|
||||
default:
|
||||
throw new \Error("LoopBreak");
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$apiMock->handleUpdates($handlers);
|
||||
} catch (\Error $e) {
|
||||
$this->assertSame('LoopBreak', $e->getMessage());
|
||||
$this->assertSame(
|
||||
3,
|
||||
$this->processUpdatesBatchCallCount,
|
||||
'processUpdatesBatch should have been called 3 times.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
@@ -825,46 +631,6 @@ final class ApiTest extends TestCase
|
||||
new Api('some-token');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleUpdatesLoopCatchesGenericExceptionAndContinues(): void
|
||||
{
|
||||
$this->processUpdatesBatchCallCount = 0;
|
||||
$handlers = [UpdateType::MessageCreated->value => fn() => null];
|
||||
|
||||
$apiMock = $this->getMockBuilder(Api::class)
|
||||
->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 () {
|
||||
switch ($this->processUpdatesBatchCallCount++) {
|
||||
case 0:
|
||||
return;
|
||||
case 1:
|
||||
throw new \BushlanovDev\MaxMessengerBot\Exceptions\SerializationException(
|
||||
"Simulated JSON error"
|
||||
);
|
||||
default:
|
||||
throw new \Error("LoopBreak");
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$apiMock->handleUpdates($handlers);
|
||||
} catch (\Error $e) {
|
||||
$this->assertSame('LoopBreak', $e->getMessage());
|
||||
$this->assertSame(
|
||||
3,
|
||||
$this->processUpdatesBatchCallCount,
|
||||
'processUpdatesBatch should have been called 3 times, indicating the loop continued after the exception.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function uploadAttachmentThrowsRuntimeExceptionWhenPathIsADirectory(): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\NetworkException;
|
||||
use BushlanovDev\MaxMessengerBot\LongPollingHandler;
|
||||
use BushlanovDev\MaxMessengerBot\Models\UpdateList;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\User;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use Error;
|
||||
use Exception;
|
||||
use phpmock\phpunit\PHPMock;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[CoversClass(LongPollingHandler::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(UpdateList::class)]
|
||||
final class LongPollingHandlerTest extends TestCase
|
||||
{
|
||||
use PHPMock;
|
||||
|
||||
/**
|
||||
* @param AbstractUpdate[] $updatesToReturn
|
||||
* @param int $expectedDispatchCount
|
||||
* @param int|null $expectedMarker
|
||||
*/
|
||||
#[DataProvider('processSingleBatchProvider')]
|
||||
public function testProcessSingleBatch(
|
||||
array $updatesToReturn,
|
||||
int $expectedDispatchCount,
|
||||
?int $expectedMarker
|
||||
): void {
|
||||
$apiMock = $this->createMock(Api::class);
|
||||
$loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$dispatcher = new UpdateDispatcher($apiMock);
|
||||
|
||||
$updateList = new UpdateList($updatesToReturn, $expectedMarker);
|
||||
|
||||
$apiMock->expects($this->once())
|
||||
->method('getUpdates')
|
||||
->with($this->isNull(), $this->equalTo(90), $this->isNull())
|
||||
->willReturn($updateList);
|
||||
|
||||
$dispatchCount = 0;
|
||||
$dispatcher->addHandler(UpdateType::BotStarted, function () use (&$dispatchCount) {
|
||||
$dispatchCount++;
|
||||
});
|
||||
|
||||
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
|
||||
|
||||
$returnedMarker = $handler->processSingleBatch(90, null);
|
||||
|
||||
$this->assertSame(
|
||||
$expectedDispatchCount,
|
||||
$dispatchCount,
|
||||
"Dispatcher should be called $expectedDispatchCount times."
|
||||
);
|
||||
$this->assertSame($expectedMarker, $returnedMarker, 'Method should return the correct marker.');
|
||||
}
|
||||
|
||||
public static function processSingleBatchProvider(): array
|
||||
{
|
||||
$user = new User(1, 'Test', null, null, false, time());
|
||||
$update1 = new BotStartedUpdate(time(), 1, $user, null, null);
|
||||
$update2 = new BotStartedUpdate(time(), 2, $user, null, null);
|
||||
|
||||
return [
|
||||
'with two updates' => [
|
||||
'updatesToReturn' => [$update1, $update2],
|
||||
'expectedDispatchCount' => 2,
|
||||
'expectedMarker' => 12345,
|
||||
],
|
||||
'with no updates' => [
|
||||
'updatesToReturn' => [],
|
||||
'expectedDispatchCount' => 0,
|
||||
'expectedMarker' => 54321,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
#[PreserveGlobalState(false)]
|
||||
#[RunInSeparateProcess]
|
||||
public function testRunCatchesNetworkExceptionAndSleeps5Seconds(): void
|
||||
{
|
||||
$apiMock = $this->createMock(Api::class);
|
||||
$loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$dispatcher = new UpdateDispatcher($apiMock);
|
||||
|
||||
$sleepMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'sleep');
|
||||
$sleepMock->expects($this->once())->with(5);
|
||||
|
||||
$apiMock->expects($this->exactly(2))
|
||||
->method('getUpdates')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
$this->throwException(new NetworkException('Connection timeout')),
|
||||
$this->throwException(new Error('Stop test loop')),
|
||||
);
|
||||
|
||||
$loggerMock->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('Long-polling network error'), $this->anything());
|
||||
|
||||
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
|
||||
|
||||
try {
|
||||
$handler->handle();
|
||||
} catch (Error $e) {
|
||||
$this->assertSame('Stop test loop', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
#[PreserveGlobalState(false)]
|
||||
#[RunInSeparateProcess]
|
||||
public function testRunCatchesGenericExceptionAndSleeps1Second(): void
|
||||
{
|
||||
$apiMock = $this->createMock(Api::class);
|
||||
$loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$dispatcher = new UpdateDispatcher($apiMock);
|
||||
|
||||
$sleepMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'sleep');
|
||||
$sleepMock->expects($this->once())->with(1);
|
||||
|
||||
$apiMock->expects($this->exactly(2))
|
||||
->method('getUpdates')
|
||||
->willReturnOnConsecutiveCalls(
|
||||
$this->throwException(new Exception('Something went wrong')),
|
||||
$this->throwException(new Error('Stop test loop')),
|
||||
);
|
||||
|
||||
$loggerMock->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('An error occurred during long-polling'), $this->anything());
|
||||
|
||||
$handler = new LongPollingHandler($apiMock, $dispatcher, $loggerMock);
|
||||
|
||||
try {
|
||||
$handler->handle();
|
||||
} catch (Error $e) {
|
||||
$this->assertSame('Stop test loop', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\ChatType;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Message;
|
||||
use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Recipient;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\BotStartedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\User;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
#[CoversClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(User::class)]
|
||||
#[UsesClass(BotStartedUpdate::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
#[UsesClass(MessageBody::class)]
|
||||
#[UsesClass(Recipient::class)]
|
||||
#[UsesClass(MessageCreatedUpdate::class)]
|
||||
final class UpdateDispatcherTest extends TestCase
|
||||
{
|
||||
private Api $apiMock;
|
||||
private UpdateDispatcher $dispatcher;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->dispatcher = new UpdateDispatcher($this->apiMock);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function addHandlerAndDispatch(): void
|
||||
{
|
||||
$wasCalled = false;
|
||||
|
||||
$user = new User(100, 'Test', 'User', 'testuser', false, time());
|
||||
$update = new BotStartedUpdate(time(), 12345, $user, null, 'ru-RU');
|
||||
|
||||
$this->dispatcher->addHandler(
|
||||
UpdateType::BotStarted,
|
||||
function ($receivedUpdate, $receivedApi) use (&$wasCalled, $update) {
|
||||
$this->assertSame($update, $receivedUpdate);
|
||||
$this->assertSame($this->apiMock, $receivedApi);
|
||||
$wasCalled = true;
|
||||
}
|
||||
);
|
||||
|
||||
$this->dispatcher->dispatch($update);
|
||||
|
||||
$this->assertTrue($wasCalled, 'Handler for BotStarted update was not called.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function onCommandDispatch(): void
|
||||
{
|
||||
$commandCalled = false;
|
||||
$messageHandlerCalled = false;
|
||||
|
||||
$messageBody = new MessageBody('mid1', 1, '/start with args', null, null);
|
||||
$sender = new User(101, 'Cmd', 'Sender', 'cmdsender', false, time());
|
||||
$recipient = new Recipient(ChatType::Dialog, 101, null);
|
||||
$message = new Message(time(), $recipient, $messageBody, $sender, null, null, null);
|
||||
$update = new MessageCreatedUpdate(time(), $message, 'ru-RU');
|
||||
|
||||
$this->dispatcher->onCommand('/start', function ($receivedUpdate) use (&$commandCalled, $update) {
|
||||
$this->assertSame($update, $receivedUpdate);
|
||||
$commandCalled = true;
|
||||
});
|
||||
|
||||
$this->dispatcher->onMessageCreated(function () use (&$messageHandlerCalled) {
|
||||
$messageHandlerCalled = true;
|
||||
});
|
||||
|
||||
$this->dispatcher->dispatch($update);
|
||||
|
||||
$this->assertTrue($commandCalled, 'onCommand handler was not called.');
|
||||
$this->assertFalse(
|
||||
$messageHandlerCalled,
|
||||
'onMessageCreated handler should not be called when a command matches.',
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function messageWithoutCommandTriggersGenericHandler(): void
|
||||
{
|
||||
$commandCalled = false;
|
||||
$messageHandlerCalled = false;
|
||||
|
||||
$messageBody = new MessageBody('mid2', 2, 'Hello world', null, null);
|
||||
$sender = new User(102, 'Msg', 'Sender', 'msgsender', false, time());
|
||||
$recipient = new Recipient(ChatType::Dialog, 102, null);
|
||||
$message = new Message(time(), $recipient, $messageBody, $sender, null, null, null);
|
||||
$update = new MessageCreatedUpdate(time(), $message, 'en-US');
|
||||
|
||||
$this->dispatcher->onCommand('/start', function () use (&$commandCalled) {
|
||||
$commandCalled = true;
|
||||
});
|
||||
|
||||
$this->dispatcher->onMessageCreated(function ($receivedUpdate) use (&$messageHandlerCalled, $update) {
|
||||
$this->assertSame($update, $receivedUpdate);
|
||||
$messageHandlerCalled = true;
|
||||
});
|
||||
|
||||
$this->dispatcher->dispatch($update);
|
||||
|
||||
$this->assertFalse($commandCalled, 'onCommand handler should not be called for a regular message.');
|
||||
$this->assertTrue($messageHandlerCalled, 'onMessageCreated handler was not called.');
|
||||
}
|
||||
}
|
||||
+84
-181
@@ -6,6 +6,7 @@ namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\ChatType;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
@@ -14,20 +15,24 @@ use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Recipient;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use LogicException;
|
||||
use phpmock\phpunit\PHPMock;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[CoversClass(WebhookHandler::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
#[UsesClass(MessageBody::class)]
|
||||
#[UsesClass(Recipient::class)]
|
||||
@@ -37,249 +42,147 @@ final class WebhookHandlerTest extends TestCase
|
||||
{
|
||||
use PHPMock;
|
||||
|
||||
private const string SECRET = 'my-secret-key';
|
||||
|
||||
private MockObject&Api $apiMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private UpdateDispatcher $dispatcher;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
|
||||
private const string SECRET = 'my-super-secret-key';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$this->dispatcher = new UpdateDispatcher($this->apiMock);
|
||||
}
|
||||
|
||||
private function createValidUpdatePayload(): string
|
||||
private function createValidUpdate(): MessageCreatedUpdate
|
||||
{
|
||||
return json_encode([
|
||||
'update_type' => 'message_created',
|
||||
'timestamp' => 1678886400,
|
||||
'message' => [
|
||||
'timestamp' => 1678886400,
|
||||
'body' => ['mid' => 'm.123', 'seq' => 1, 'text' => 'Hello World'],
|
||||
'recipient' => ['chat_type' => 'dialog', 'chat_id' => 101, 'user_id' => 101],
|
||||
'sender' => null,
|
||||
'url' => null,
|
||||
],
|
||||
'user_locale' => 'ru-RU',
|
||||
]);
|
||||
$messageBody = new MessageBody('m.1', 1, 'Hi', null, null);
|
||||
$recipient = new Recipient(ChatType::Dialog, 1, null);
|
||||
$message = new Message(time(), $recipient, $messageBody, null, null, null, null);
|
||||
|
||||
return new MessageCreatedUpdate(time(), $message, 'ru-RU');
|
||||
}
|
||||
|
||||
private function createRealUpdateObject(array $data): MessageCreatedUpdate
|
||||
private function createMockRequest(string $body, string $signature): ServerRequestInterface
|
||||
{
|
||||
$messageBody = new MessageBody(
|
||||
$data['message']['body']['mid'],
|
||||
$data['message']['body']['seq'],
|
||||
$data['message']['body']['text'],
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$recipient = new Recipient(
|
||||
ChatType::from($data['message']['recipient']['chat_type']),
|
||||
$data['message']['recipient']['user_id'],
|
||||
$data['message']['recipient']['chat_id']
|
||||
);
|
||||
$message = new Message(
|
||||
$data['message']['timestamp'],
|
||||
$recipient,
|
||||
$messageBody,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$streamMock = $this->createMock(StreamInterface::class);
|
||||
$streamMock->method('__toString')->willReturn($body);
|
||||
|
||||
return new MessageCreatedUpdate(
|
||||
$data['timestamp'],
|
||||
$message,
|
||||
$data['user_locale']
|
||||
);
|
||||
$requestMock = $this->createMock(ServerRequestInterface::class);
|
||||
$requestMock->method('getBody')->willReturn($streamMock);
|
||||
$requestMock->method('getHeaderLine')->with('X-Max-Bot-Api-Secret')->willReturn($signature);
|
||||
|
||||
return $requestMock;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleMethodProcessesPsr7RequestAndDispatches(): void
|
||||
#[DataProvider('successfulRequestProvider')]
|
||||
public function handleSuccessfulRequest(?string $secret, string $signatureHeader): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$signature = self::SECRET;
|
||||
$payload = '{"update_type":"message_created","timestamp":123}';
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
$expectedUpdate = $this->createValidUpdate();
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => $signature], $payload
|
||||
);
|
||||
$request = $this->createMockRequest($payload, $signatureHeader);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
$this->modelFactoryMock->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with($updateData)
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$handlerWasCalled = false;
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET);
|
||||
$webhookHandler->onMessageCreated(function (AbstractUpdate $update) use (&$handlerWasCalled, $expectedUpdate) {
|
||||
$this->assertSame($expectedUpdate, $update);
|
||||
$this->dispatcher->addHandler(UpdateType::MessageCreated, function () use (&$handlerWasCalled) {
|
||||
$handlerWasCalled = true;
|
||||
});
|
||||
|
||||
$webhookHandler->handle($request);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, $secret);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'The registered handler was not dispatched from handle() method.');
|
||||
$handler->handle($request);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'Dispatcher was not called on successful request.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dispatchCallsCorrectHandlerForRegisteredEvent(): void
|
||||
public static function successfulRequestProvider(): array
|
||||
{
|
||||
$handlerWasCalled = false;
|
||||
$updateData = json_decode($this->createValidUpdatePayload(), true);
|
||||
$testUpdate = $this->createRealUpdateObject($updateData);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
|
||||
$webhookHandler->onMessageCreated(
|
||||
function (MessageCreatedUpdate $update, Api $api) use (&$handlerWasCalled, $testUpdate) {
|
||||
$this->assertSame($testUpdate, $update);
|
||||
$this->assertSame($this->apiMock, $api);
|
||||
$handlerWasCalled = true;
|
||||
}
|
||||
);
|
||||
|
||||
$webhookHandler->dispatch($testUpdate);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'The registered handler for onMessageCreated was not called.');
|
||||
return [
|
||||
'with correct secret' => [self::SECRET, self::SECRET],
|
||||
'with no secret configured' => [null, 'any-signature'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dispatchDoesNothingForUnregisteredEvent(): void
|
||||
public function handleThrowsSecurityExceptionOnInvalidSignature(): void
|
||||
{
|
||||
$updateData = json_decode($this->createValidUpdatePayload(), true);
|
||||
$testUpdate = $this->createRealUpdateObject($updateData);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
|
||||
$webhookHandler->dispatch($testUpdate);
|
||||
|
||||
$this->expectNotToPerformAssertions();
|
||||
$this->expectException(SecurityException::class);
|
||||
$request = $this->createMockRequest('{}', 'wrong-signature');
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
$handler->handle($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForEmptyPayload(): void
|
||||
public function handleLogsWarningOnSignatureFailure(): void
|
||||
{
|
||||
$this->loggerMock->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Webhook signature verification failed', ['received_signature' => 'wrong-signature']);
|
||||
|
||||
$request = $this->createMockRequest('{}', 'wrong-signature');
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
|
||||
try {
|
||||
$handler->handle($request);
|
||||
} catch (SecurityException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleThrowsSerializationExceptionOnEmptyBody(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], '');
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
$request = $this->createMockRequest('', self::SECRET);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
$handler->handle($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForInvalidJson(): void
|
||||
public function handleThrowsSerializationExceptionOnInvalidJson(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Failed to decode webhook body as JSON.');
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], '{invalid-json');
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForInvalidSignature(): void
|
||||
{
|
||||
$this->expectException(SecurityException::class);
|
||||
$this->expectExceptionMessage('Signature verification failed.');
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'wrong-signature'], $this->createValidUpdatePayload()
|
||||
);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function signatureVerificationIsSkippedWhenNoSecretIsConfigured(): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'any-signature-or-empty'], $payload
|
||||
);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, null);
|
||||
|
||||
$result = $webhookHandler->parseUpdate($request);
|
||||
|
||||
$this->assertSame($expectedUpdate, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getUpdateParsesRequestAndReturnsUpdateObject(): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], $payload);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with($updateData)
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$result = $webhookHandler->getUpdate($request);
|
||||
|
||||
$this->assertSame($expectedUpdate, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleWithoutRequestWhenGuzzleIsPresent(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->handle(null);
|
||||
$request = $this->createMockRequest('{invalid-json', self::SECRET);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
$handler->handle($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
public function handleWithoutRequestWhenGuzzleIsMissing(): void
|
||||
public function handleWithoutRequestThrowsLogicExceptionWhenGuzzleIsMissing(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessageMatches('/No ServerRequest was provided and "guzzlehttp\/psr7" is not found/');
|
||||
|
||||
$classExistsMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'class_exists');
|
||||
|
||||
$classExistsMock->expects($this->once())->with('GuzzleHttp\Psr7\ServerRequest')->willReturn(false);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->handle(null);
|
||||
$classExistsMock->expects($this->once())
|
||||
->with(\GuzzleHttp\Psr7\ServerRequest::class)
|
||||
->willReturn(false);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, null);
|
||||
$handler->handle(null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function verifySignatureLogsWarningOnFailure(): void
|
||||
public function handleWithoutRequestWhenGuzzleIsPresent(): 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);
|
||||
if (!class_exists(\GuzzleHttp\Psr7\ServerRequest::class)) {
|
||||
$this->markTestSkipped('guzzlehttp/psr7 is not installed, cannot run this test.');
|
||||
}
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, null);
|
||||
$handler->handle(null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user