From 9cd5dd2c48f74ede99dd32f4a4aaf539c637860d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 Aug 2025 22:51:35 +0300 Subject: [PATCH] Refactoring update dispatchers --- src/Api.php | 142 ++++------------- src/LongPollingHandler.php | 74 +++++++++ src/UpdateDispatcher.php | 231 +++++++++++++++++++++++++++ src/WebhookHandler.php | 248 +++-------------------------- tests/ApiFactoryMethodsTest.php | 105 ++++++++++++ tests/ApiTest.php | 238 +-------------------------- tests/LongPollingHandlerTest.php | 154 ++++++++++++++++++ tests/UpdateDispatcherTest.php | 118 ++++++++++++++ tests/WebhookHandlerTest.php | 265 ++++++++++--------------------- 9 files changed, 818 insertions(+), 757 deletions(-) create mode 100644 src/LongPollingHandler.php create mode 100644 src/UpdateDispatcher.php create mode 100644 tests/ApiFactoryMethodsTest.php create mode 100644 tests/LongPollingHandlerTest.php create mode 100644 tests/UpdateDispatcherTest.php diff --git a/src/Api.php b/src/Api.php index 72f5446..641c30d 100644 --- a/src/Api.php +++ b/src/Api.php @@ -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 $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 $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 $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. * diff --git a/src/LongPollingHandler.php b/src/LongPollingHandler.php new file mode 100644 index 0000000..b9651cb --- /dev/null +++ b/src/LongPollingHandler.php @@ -0,0 +1,74 @@ +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); + } + } + } +} diff --git a/src/UpdateDispatcher.php b/src/UpdateDispatcher.php new file mode 100644 index 0000000..69befc1 --- /dev/null +++ b/src/UpdateDispatcher.php @@ -0,0 +1,231 @@ + + */ + private array $handlers = []; + + /** + * @var array + */ + 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); + } +} diff --git a/src/WebhookHandler.php b/src/WebhookHandler.php index 768d35a..7f221c5 100644 --- a/src/WebhookHandler.php +++ b/src/WebhookHandler.php @@ -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 - */ - 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); } } diff --git a/tests/ApiFactoryMethodsTest.php b/tests/ApiFactoryMethodsTest.php new file mode 100644 index 0000000..2fef8c0 --- /dev/null +++ b/tests/ApiFactoryMethodsTest.php @@ -0,0 +1,105 @@ +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); + } +} diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 784ea15..0f4b051 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -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 { diff --git a/tests/LongPollingHandlerTest.php b/tests/LongPollingHandlerTest.php new file mode 100644 index 0000000..207e1ec --- /dev/null +++ b/tests/LongPollingHandlerTest.php @@ -0,0 +1,154 @@ +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()); + } + } +} diff --git a/tests/UpdateDispatcherTest.php b/tests/UpdateDispatcherTest.php new file mode 100644 index 0000000..9a71235 --- /dev/null +++ b/tests/UpdateDispatcherTest.php @@ -0,0 +1,118 @@ +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.'); + } +} diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php index bd4e931..48eae25 100644 --- a/tests/WebhookHandlerTest.php +++ b/tests/WebhookHandlerTest.php @@ -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); } }