diff --git a/src/Laravel/MaxBotManager.php b/src/Laravel/MaxBotManager.php index 35fbfec..48de2f2 100644 --- a/src/Laravel/MaxBotManager.php +++ b/src/Laravel/MaxBotManager.php @@ -113,6 +113,7 @@ class MaxBotManager * It will run indefinitely until stopped. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function startLongPolling(int $timeout = 90, ?int $marker = null): void { @@ -129,6 +130,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function addHandler(UpdateType $type, callable|string $handler): void { @@ -142,6 +144,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onCommand(string $command, callable|string $handler): void { @@ -154,6 +157,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onMessageCreated(callable|string $handler): void { @@ -166,6 +170,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onMessageCallback(callable|string $handler): void { @@ -178,6 +183,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onMessageEdited(callable|string $handler): void { @@ -190,6 +196,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onMessageRemoved(callable|string $handler): void { @@ -202,6 +209,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onBotAdded(callable|string $handler): void { @@ -214,6 +222,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onBotRemoved(callable|string $handler): void { @@ -226,6 +235,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onUserAdded(callable|string $handler): void { @@ -238,6 +248,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onUserRemoved(callable|string $handler): void { @@ -250,6 +261,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onBotStarted(callable|string $handler): void { @@ -262,6 +274,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onChatTitleChanged(callable|string $handler): void { @@ -274,6 +287,7 @@ class MaxBotManager * @param callable|string $handler Can be a closure, callable, or Laravel container binding. * * @throws BindingResolutionException + * @codeCoverageIgnore */ public function onMessageChatCreated(callable|string $handler): void { @@ -282,6 +296,7 @@ class MaxBotManager /** * Get the API instance. + * @codeCoverageIgnore */ public function getApi(): Api { @@ -290,6 +305,7 @@ class MaxBotManager /** * Get the update dispatcher. + * @codeCoverageIgnore */ public function getDispatcher(): UpdateDispatcher { diff --git a/src/Laravel/MaxBotServiceProvider.php b/src/Laravel/MaxBotServiceProvider.php index d515738..8ae6c86 100644 --- a/src/Laravel/MaxBotServiceProvider.php +++ b/src/Laravel/MaxBotServiceProvider.php @@ -7,6 +7,7 @@ namespace BushlanovDev\MaxMessengerBot\Laravel; use BushlanovDev\MaxMessengerBot\Api; use BushlanovDev\MaxMessengerBot\Client; use BushlanovDev\MaxMessengerBot\ClientApiInterface; +use BushlanovDev\MaxMessengerBot\Laravel\Commands\PollingStartCommand; use BushlanovDev\MaxMessengerBot\ModelFactory; use BushlanovDev\MaxMessengerBot\UpdateDispatcher; use BushlanovDev\MaxMessengerBot\WebhookHandler; @@ -19,6 +20,7 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Contracts\Config\Repository as Config; use Psr\Log\LoggerInterface; use InvalidArgumentException; +use Psr\Log\NullLogger; /** * Laravel Service Provider for Max Bot API Client. @@ -37,6 +39,7 @@ class MaxBotServiceProvider extends ServiceProvider ); $this->app->singleton(ClientApiInterface::class, function (Application $app) { + /** @var Config $config */ $config = $app->make(Config::class); $accessToken = $config->get('maxbot.access_token'); @@ -52,16 +55,14 @@ class MaxBotServiceProvider extends ServiceProvider ); } - $timeout = $config->get('maxbot.timeout', 10); - $connectTimeout = $config->get('maxbot.connect_timeout', 5); - $readTimeout = $config->get('maxbot.read_timeout', 10); - $baseUrl = $config->get('maxbot.base_url', 'https://botapi.max.ru'); - $apiVersion = $config->get('maxbot.api_version', Api::API_VERSION); + $logger = $config->get('maxbot.logging.enabled', false) + ? $app->make(LoggerInterface::class) + : new NullLogger(); $guzzle = new \GuzzleHttp\Client([ - 'timeout' => $timeout, - 'connect_timeout' => $connectTimeout, - 'read_timeout' => $readTimeout, + 'timeout' => (int)$config->get('maxbot.timeout', 10), + 'connect_timeout' => (int)$config->get('maxbot.connect_timeout', 5), + 'read_timeout' => (int)$config->get('maxbot.read_timeout', 10), 'headers' => [ 'User-Agent' => 'max-bot-api-client-php/' . Api::LIBRARY_VERSION . ' Laravel/' . $app->version() . ' PHP/' . PHP_VERSION @@ -75,9 +76,9 @@ class MaxBotServiceProvider extends ServiceProvider $guzzle, $httpFactory, $httpFactory, - $baseUrl, - $apiVersion, - $app->make(LoggerInterface::class), + $config->get('maxbot.base_url', 'https://botapi.max.ru'), + $config->get('maxbot.api_version', Api::API_VERSION), + $logger, ); }); @@ -90,6 +91,7 @@ class MaxBotServiceProvider extends ServiceProvider }); $this->app->singleton(Api::class, function (Application $app) { + /** @var Config $config */ $config = $app->make(Config::class); $accessToken = $config->get('maxbot.access_token'); @@ -109,6 +111,7 @@ class MaxBotServiceProvider extends ServiceProvider }); $this->app->bind(WebhookHandler::class, function (Application $app) { + /** @var Config $config */ $config = $app->make(Config::class); $secret = $config->get('maxbot.webhook_secret'); @@ -159,6 +162,7 @@ class MaxBotServiceProvider extends ServiceProvider WebhookSubscribeCommand::class, WebhookUnsubscribeCommand::class, WebhookListCommand::class, + PollingStartCommand::class, ]); } } diff --git a/src/Laravel/config/maxbot.php b/src/Laravel/config/maxbot.php index a88c156..e0a7e3d 100644 --- a/src/Laravel/config/maxbot.php +++ b/src/Laravel/config/maxbot.php @@ -2,6 +2,7 @@ declare(strict_types=1); +// @codeCoverageIgnoreStart return [ /* |-------------------------------------------------------------------------- @@ -65,3 +66,4 @@ return [ 'level' => env('MAXBOT_LOGGING_LEVEL', 'debug'), ], ]; +// @codeCoverageIgnoreEnd diff --git a/tests/Laravel/Commands/PollingStartCommandTest.php b/tests/Laravel/Commands/PollingStartCommandTest.php new file mode 100644 index 0000000..e768513 --- /dev/null +++ b/tests/Laravel/Commands/PollingStartCommandTest.php @@ -0,0 +1,103 @@ +botManagerMock = $this->createMock(MaxBotManager::class); + $this->container->instance(MaxBotManager::class, $this->botManagerMock); + $this->container->alias(MaxBotManager::class, 'maxbot.manager'); + + $this->command = new PollingStartCommand(); + $this->command->setLaravel($this->container); + + $application = new ConsoleApplication(); + $application->add($this->command); + $commandInApp = $application->find('maxbot:polling:start'); + $this->tester = new CommandTester($commandInApp); + } + + #[Test] + public function handleSuccessfullyCallsManagerWithCustomTimeout(): void + { + $timeout = 60; + + $this->botManagerMock + ->expects($this->once()) + ->method('startLongPolling') + ->with($timeout); + + $this->tester->execute(['--timeout' => $timeout]); + $this->tester->assertCommandIsSuccessful(); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString("Starting long polling with a timeout of $timeout seconds...", $output); + } + + #[Test] + public function handleSuccessfullyUsesDefaultTimeout(): void + { + $defaultTimeout = 90; + + $this->botManagerMock + ->expects($this->once()) + ->method('startLongPolling') + ->with($defaultTimeout); + + $this->tester->execute([]); + $this->tester->assertCommandIsSuccessful(); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString( + "Starting long polling with a timeout of $defaultTimeout seconds...", + $output + ); + } + + #[Test] + public function handleCatchesExceptionAndLogsError(): void + { + $exceptionMessage = 'Something went wrong'; + $exception = new \RuntimeException($exceptionMessage); + + $this->botManagerMock + ->expects($this->once()) + ->method('startLongPolling') + ->willThrowException($exception); + + Log::shouldReceive('error') + ->once() + ->with( + "Long polling failed to start or crashed: $exceptionMessage", + ['exception' => $exception], + ); + + $statusCode = $this->tester->execute([]); + + $this->assertSame(1, $statusCode, 'Command should return a failure exit code.'); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString("❌ Long polling failed: $exceptionMessage", $output); + } +} diff --git a/tests/Laravel/Commands/TestCase.php b/tests/Laravel/Commands/TestCase.php new file mode 100644 index 0000000..9be542c --- /dev/null +++ b/tests/Laravel/Commands/TestCase.php @@ -0,0 +1,48 @@ +container = new TestApplicationContainer(); + Container::setInstance($this->container); + Facade::setFacadeApplication($this->container); + + $loggerMock = $this->createMock(LoggerInterface::class); + $this->container->instance('log', $loggerMock); + } + + protected function tearDown(): void + { + Container::setInstance(null); + Facade::clearResolvedInstances(); + + if (class_exists(\Mockery::class)) { + \Mockery::close(); + } + parent::tearDown(); + } +} + +class TestApplicationContainer extends Container +{ + public function runningUnitTests(): bool + { + return true; + } +} diff --git a/tests/Laravel/Commands/WebhookListCommandTest.php b/tests/Laravel/Commands/WebhookListCommandTest.php new file mode 100644 index 0000000..558741e --- /dev/null +++ b/tests/Laravel/Commands/WebhookListCommandTest.php @@ -0,0 +1,118 @@ +apiMock = $this->createMock(Api::class); + $this->container->instance(Api::class, $this->apiMock); + + $this->command = new WebhookListCommand(); + $this->command->setLaravel($this->container); + + $application = new ConsoleApplication(); + $application->add($this->command); + $commandInApp = $application->find('maxbot:webhook:list'); + $this->tester = new CommandTester($commandInApp); + } + + #[Test] + public function handleDisplaysTableWithActiveSubscriptions(): void + { + $timestamp = 1678886400; // 2023-03-15 13:20:00 UTC + $subscriptions = [ + new Subscription( + 'https://example.com/hook1', + $timestamp, + [UpdateType::MessageCreated, UpdateType::BotStarted], + '0.0.6' + ), + new Subscription( + 'https://example.com/hook2', + $timestamp + 3600, + null, // Should be rendered as 'all' + '0.0.6' + ), + ]; + + $this->apiMock + ->expects($this->once()) + ->method('getSubscriptions') + ->willReturn($subscriptions); + + $this->tester->execute([]); + $this->tester->assertCommandIsSuccessful(); + + $output = $this->tester->getDisplay(); + + $this->assertStringContainsString('https://example.com/hook1', $output); + $this->assertStringContainsString('https://example.com/hook2', $output); + $this->assertStringContainsString('message_created, bot_started', $output); + $this->assertStringContainsString('all', $output); + $this->assertStringContainsString(date('Y-m-d H:i:s', $timestamp), $output); + $this->assertStringContainsString(date('Y-m-d H:i:s', $timestamp + 3600), $output); + } + + #[Test] + public function handleDisplaysMessageWhenNoSubscriptionsExist(): void + { + $this->apiMock + ->expects($this->once()) + ->method('getSubscriptions') + ->willReturn([]); + + $this->tester->execute([]); + $this->tester->assertCommandIsSuccessful(); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('No active webhook subscriptions found.', $output); + $this->assertStringNotContainsString('URL', $output, 'Table headers should not be displayed.'); + } + + #[Test] + public function handleCatchesExceptionAndLogsError(): void + { + $exceptionMessage = 'API is down'; + $exception = new \RuntimeException($exceptionMessage); + + $this->apiMock + ->expects($this->once()) + ->method('getSubscriptions') + ->willThrowException($exception); + + Log::shouldReceive('error') + ->once() + ->with("Webhook list error: $exceptionMessage", ['exception' => $exception]); + + $statusCode = $this->tester->execute([]); + + $this->assertSame(1, $statusCode, 'Command should return a failure exit code.'); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString("❌ Webhook list error: $exceptionMessage", $output); + } +} diff --git a/tests/Laravel/Commands/WebhookSubscribeCommandTest.php b/tests/Laravel/Commands/WebhookSubscribeCommandTest.php new file mode 100644 index 0000000..910de25 --- /dev/null +++ b/tests/Laravel/Commands/WebhookSubscribeCommandTest.php @@ -0,0 +1,163 @@ +apiMock = $this->createMock(Api::class); + $this->configMock = $this->createMock(Config::class); + $this->container->instance(Api::class, $this->apiMock); + $this->container->instance(Config::class, $this->configMock); + + $this->command = new WebhookSubscribeCommand(); + $this->command->setLaravel($this->container); + + $application = new ConsoleApplication(); + $application->add($this->command); + $commandInApp = $application->find('maxbot:webhook:subscribe'); + $this->tester = new CommandTester($commandInApp); + } + + #[Test] + public function handleSuccessfullySubscribesWithAllOptions(): void + { + $url = 'https://example.com/webhook'; + $secret = 'my-super-secret'; + $types = ['message_created', 'bot_started']; + $expectedUpdateTypes = [UpdateType::MessageCreated, UpdateType::BotStarted]; + + $this->apiMock + ->expects($this->once()) + ->method('subscribe') + ->with($url, $secret, $expectedUpdateTypes) + ->willReturn(new Result(true, null)); + + $this->tester->execute([ + 'url' => $url, + '--secret' => $secret, + '--types' => $types, + ]); + $this->tester->assertCommandIsSuccessful(); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('✅ Successfully subscribed to webhook!', $output); + $this->assertStringContainsString("URL: $url", $output); + $this->assertStringContainsString("Secret: ***************", $output); + $this->assertStringContainsString("Update types: message_created, bot_started", $output); + } + + #[Test] + public function handleUsesSecretFromConfigWhenOptionIsNotProvided(): void + { + $url = 'https://example.com/webhook'; + $configSecret = 'secret-from-config'; + + $this->configMock + ->expects($this->once()) + ->method('get') + ->with('maxbot.webhook_secret') + ->willReturn($configSecret); + + $this->apiMock + ->expects($this->once()) + ->method('subscribe') + ->with($url, $configSecret, null) + ->willReturn(new Result(true, null)); + + $this->tester->execute(['url' => $url]); + $this->tester->assertCommandIsSuccessful(); + } + + #[Test] + public function handleFailsForInvalidUrl(): void + { + $this->apiMock->expects($this->never())->method('subscribe'); + + $statusCode = $this->tester->execute(['url' => 'not-a-valid-url']); + + $this->assertSame(1, $statusCode); + $this->assertStringContainsString('Invalid URL provided.', $this->tester->getDisplay()); + } + + #[Test] + public function handleFailsForInvalidUpdateType(): void + { + $this->apiMock->expects($this->never())->method('subscribe'); + + $statusCode = $this->tester->execute([ + 'url' => 'https://example.com', + '--types' => ['message_created', 'invalid_type'], + ]); + + $this->assertSame(1, $statusCode); + $this->assertStringContainsString('Invalid update type: invalid_type', $this->tester->getDisplay()); + } + + #[Test] + public function handleDisplaysApiErrorMessageOnFailure(): void + { + $url = 'https://example.com/webhook'; + $apiErrorMessage = 'URL is already subscribed'; + + $this->apiMock + ->expects($this->once()) + ->method('subscribe') + ->willReturn(new Result(false, $apiErrorMessage)); + + $statusCode = $this->tester->execute(['url' => $url]); + + $this->assertSame(1, $statusCode); + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('❌ Failed to subscribe to webhook.', $output); + $this->assertStringContainsString("Response: $apiErrorMessage", $output); + } + + #[Test] + public function handleCatchesExceptionAndLogsError(): void + { + $url = 'https://example.com/webhook'; + $exceptionMessage = 'Network error'; + $exception = new \RuntimeException($exceptionMessage); + + $this->apiMock + ->expects($this->once()) + ->method('subscribe') + ->willThrowException($exception); + + Log::shouldReceive('error') + ->once() + ->with("Webhook subscription error: $exceptionMessage", ['exception' => $exception]); + + $statusCode = $this->tester->execute(['url' => $url]); + + $this->assertSame(1, $statusCode); + $output = $this->tester->getDisplay(); + $this->assertStringContainsString("❌ Webhook subscription error: $exceptionMessage", $output); + } +} diff --git a/tests/Laravel/Commands/WebhookUnsubscribeCommandTest.php b/tests/Laravel/Commands/WebhookUnsubscribeCommandTest.php new file mode 100644 index 0000000..72e4d72 --- /dev/null +++ b/tests/Laravel/Commands/WebhookUnsubscribeCommandTest.php @@ -0,0 +1,130 @@ +apiMock = $this->createMock(Api::class); + $this->container->instance(Api::class, $this->apiMock); + + $this->command = new WebhookUnsubscribeCommand(); + $this->command->setLaravel($this->container); + + $application = new ConsoleApplication(); + $application->add($this->command); + $commandInApp = $application->find('maxbot:webhook:unsubscribe'); + $this->tester = new CommandTester($commandInApp); + } + + #[Test] + public function handleSuccessfullyUnsubscribesWithConfirmationFlag(): void + { + $url = 'https://example.com/webhook'; + + $this->apiMock + ->expects($this->once()) + ->method('unsubscribe') + ->with($url) + ->willReturn(new Result(true, null)); + + $this->tester->execute([ + 'url' => $url, + '--confirm' => true, + ]); + $this->tester->assertCommandIsSuccessful(); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('✅ Successfully unsubscribed from webhook!', $output); + } + + #[Test] + public function handleCancelsWhenNotConfirmed(): void + { + $url = 'https://example.com/webhook'; + + $this->apiMock->expects($this->never())->method('unsubscribe'); + $this->tester->setInputs(['no']); + $this->tester->execute(['url' => $url]); + $this->tester->assertCommandIsSuccessful(); + + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('Are you sure you want to unsubscribe', $output); + $this->assertStringContainsString('Operation cancelled.', $output); + } + + #[Test] + public function handleFailsForInvalidUrl(): void + { + $this->apiMock->expects($this->never())->method('unsubscribe'); + + $statusCode = $this->tester->execute(['url' => 'not-a-valid-url']); + + $this->assertSame(1, $statusCode); + $this->assertStringContainsString('Invalid URL provided.', $this->tester->getDisplay()); + } + + #[Test] + public function handleDisplaysApiErrorMessageOnFailure(): void + { + $url = 'https://example.com/webhook'; + $apiErrorMessage = 'Subscription not found'; + + $this->apiMock + ->expects($this->once()) + ->method('unsubscribe') + ->willReturn(new Result(false, $apiErrorMessage)); + + $statusCode = $this->tester->execute(['url' => $url, '--confirm' => true]); + + $this->assertSame(1, $statusCode); + $output = $this->tester->getDisplay(); + $this->assertStringContainsString('❌ Failed to unsubscribe from webhook.', $output); + $this->assertStringContainsString("Response: $apiErrorMessage", $output); + } + + #[Test] + public function handleCatchesExceptionAndLogsError(): void + { + $url = 'https://example.com/webhook'; + $exceptionMessage = 'API connection refused'; + $exception = new \RuntimeException($exceptionMessage); + + $this->apiMock + ->expects($this->once()) + ->method('unsubscribe') + ->willThrowException($exception); + + Log::shouldReceive('error') + ->once() + ->with("Webhook unsubscribe error: $exceptionMessage", ['exception' => $exception]); + + $statusCode = $this->tester->execute(['url' => $url, '--confirm' => true]); + + $this->assertSame(1, $statusCode); + $output = $this->tester->getDisplay(); + $this->assertStringContainsString("❌ Webhook unsubscribe error: $exceptionMessage", $output); + } +} diff --git a/tests/Laravel/MaxBotManagerTest.php b/tests/Laravel/MaxBotManagerTest.php new file mode 100644 index 0000000..e9c3a22 --- /dev/null +++ b/tests/Laravel/MaxBotManagerTest.php @@ -0,0 +1,340 @@ +container = new Container(); + Container::setInstance($this->container); + Facade::setFacadeApplication($this->container); + + $this->container->singleton('config', function ($app) { + return $app->make(Repository::class); + }); + + $loggerMock = $this->createMock(LoggerInterface::class); + + $this->container->instance(LoggerInterface::class, $loggerMock); + $this->container->instance('log', $loggerMock); + + $this->apiMock = $this->createMock(Api::class); + $this->modelFactoryMock = $this->createMock(ModelFactory::class); + + $this->updateDispatcher = new UpdateDispatcher($this->apiMock); + + $this->container->instance(Api::class, $this->apiMock); + $this->container->instance('maxbot', $this->apiMock); + $this->container->instance(ModelFactory::class, $this->modelFactoryMock); + $this->container->instance(UpdateDispatcher::class, $this->updateDispatcher); + + $this->container->singleton(MaxBotManager::class, function ($app) { + return new MaxBotManager( + $app, + $app->make(Api::class), + $app->make(UpdateDispatcher::class), + ); + }); + $this->manager = $this->container->make(MaxBotManager::class); + + Handlers::reset(); + } + + protected function tearDown(): void + { + Container::setInstance(null); + Facade::clearResolvedInstances(); + parent::tearDown(); + } + + #[Test] + public function handleWebhookReturns200OnSuccess(): void + { + $webhookHandler = new WebhookHandler( + $this->updateDispatcher, + $this->modelFactoryMock, + $this->container->make(\Psr\Log\LoggerInterface::class), + null, + ); + $this->container->instance(WebhookHandler::class, $webhookHandler); + + $request = Request::create('/webhook', 'POST', content: '{"update_type":"message_created"}'); + + $realUpdate = new MessageCreatedUpdate( + time(), + $this->createMinimalMessage(), + 'ru-RU', + ); + + $this->modelFactoryMock->method('createUpdate')->willReturn($realUpdate); + + $wasDispatched = false; + $this->updateDispatcher->addHandler(UpdateType::MessageCreated, function () use (&$wasDispatched) { + $wasDispatched = true; + }); + + $response = $this->manager->handleWebhook($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertTrue($wasDispatched, 'The update was not dispatched correctly.'); + } + + #[Test] + public function handleWebhookReturns403OnSecurityException(): void + { + $webhookHandler = new WebhookHandler( + $this->updateDispatcher, + $this->modelFactoryMock, + $this->container->make(\Psr\Log\LoggerInterface::class), + 'real-secret', + ); + $this->container->instance(WebhookHandler::class, $webhookHandler); + + $request = Request::create('/webhook', 'POST', content: '{}'); + $request->headers->set('X-Max-Bot-Api-Secret', 'wrong-secret'); + + $response = $this->manager->handleWebhook($request); + + $this->assertSame(403, $response->getStatusCode()); + $this->assertJsonStringEqualsJsonString('{"status":"error","message":"Forbidden"}', $response->getContent()); + } + + #[Test] + public function handleWebhookReturns400OnSerializationException(): void + { + $webhookHandler = new WebhookHandler( + $this->updateDispatcher, + $this->modelFactoryMock, + $this->container->make(\Psr\Log\LoggerInterface::class), + null + ); + $this->container->instance(WebhookHandler::class, $webhookHandler); + + $request = Request::create('/webhook', 'POST', content: '{invalid-json'); + + $response = $this->manager->handleWebhook($request); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertJsonStringEqualsJsonString('{"status":"error","message":"Bad Request"}', $response->getContent()); + } + + #[Test] + public function handleWebhookReturns500OnGenericException(): void + { + $webhookHandler = new WebhookHandler( + $this->updateDispatcher, + $this->modelFactoryMock, + $this->container->make(\Psr\Log\LoggerInterface::class), + null + ); + $this->container->instance(WebhookHandler::class, $webhookHandler); + + $this->modelFactoryMock->method('createUpdate')->willThrowException(new \Exception('DB error')); + + $request = Request::create('/webhook', 'POST', content: '{"update_type":"message_created"}'); + + $response = $this->manager->handleWebhook($request); + + $this->assertSame(500, $response->getStatusCode()); + $this->assertJsonStringEqualsJsonString( + '{"status":"error","message":"Internal Server Error"}', + $response->getContent(), + ); + } + + #[Test] + public function resolveHandlerCanResolveCallable(): void + { + $wasCalled = false; + $callable = function () use (&$wasCalled) { + $wasCalled = true; + }; + + $this->manager->onCommand('test', $callable); + $dispatcher = $this->manager->getDispatcher(); + + $messageBody = new MessageBody('mid.cmd', 1, 'test', [], []); + $message = new Message(time(), new Recipient(ChatType::Dialog, 1, null), $messageBody, null, null, null, null); + $update = new MessageCreatedUpdate(time(), $message, null); + + $dispatcher->dispatch($update); + + $this->assertTrue($wasCalled, 'The resolved callable handler was not called.'); + } + + #[Test] + public function resolveHandlerResolvesClassWithHandleMethod(): void + { + $this->manager->onCommand('test', TestHandlerWithHandleMethod::class); + + $this->dispatchCommand('test'); + + $this->assertTrue(Handlers::$wasCalled, 'Handler with handle() method was not resolved and called.'); + } + + #[Test] + public function resolveHandlerResolvesInvokableClassFromContainer(): void + { + $this->container->bind(TestHandlerWithInvokeMethod::class); + + $this->manager->onCommand('test', TestHandlerWithInvokeMethod::class); + + $this->dispatchCommand('test'); + + $this->assertTrue(Handlers::$wasCalled, 'Invokable handler was not resolved and called.'); + } + + #[Test] + public function resolveHandlerResolvesClassAtMethodString(): void + { + $this->manager->onCommand('test', TestHandlerWithCustomMethod::class . '@custom'); + + $this->dispatchCommand('test'); + + $this->assertTrue(Handlers::$wasCalled, 'Handler with "Class@method" string was not resolved and called.'); + } + + #[Test] + public function resolveHandlerResolvesBoundClassWithHandleMethod(): void + { + // Шаг 1: Явно регистрируем класс обработчика в контейнере. + // Это гарантирует, что будет выбрана ветка `if ($this->container->bound($handler))`. + $this->container->bind(TestHandlerWithHandleMethod::class); + + // Шаг 2: Регистрируем обработчик, используя его имя класса (строку). + $this->manager->onCommand('test', TestHandlerWithHandleMethod::class); + + // Шаг 3: Диспетчеризуем команду, которая вызовет обработчик. + $this->dispatchCommand('test'); + + // Шаг 4: Убеждаемся, что метод `handle` был вызван. + $this->assertTrue( + Handlers::$wasCalled, + 'Bound handler with handle() method was not resolved and called via the bound path.' + ); + } + + #[Test] + public function resolveHandlerThrowsExceptionForUnresolvableString(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to resolve handler: NonExistentClass'); + + $this->manager->onCommand('test', 'NonExistentClass'); + } + + #[Test] + public function resolveHandlerThrowsExceptionForClassWithoutHandleOrInvoke(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + "Handler class '" . Handlers::class . "' is not callable and doesn't have a handle method." + ); + + $this->container->bind(Handlers::class); + + $this->manager->onCommand('test', Handlers::class); + } + + /** + * Helper to dispatch a command to the real UpdateDispatcher. + */ + private function dispatchCommand(string $commandText): void + { + $dispatcher = $this->manager->getDispatcher(); + $messageBody = new MessageBody('mid.cmd', 1, $commandText, [], []); + $message = new Message(time(), new Recipient(ChatType::Dialog, 1, null), $messageBody, null, null, null, null); + $update = new MessageCreatedUpdate(time(), $message, null); + + $dispatcher->dispatch($update); + } + + private function createMinimalMessage(): Message + { + return new Message( + time(), + new Recipient(ChatType::Dialog, 1, null), + new MessageBody('mid.1', 1, 'test', [], []), + null, + null, + null, + null, + ); + } +} + +class Handlers +{ + public static bool $wasCalled = false; + + public static function reset(): void + { + self::$wasCalled = false; + } +} + +class TestHandlerWithHandleMethod +{ + public function handle(MessageCreatedUpdate $update, Api $api): void + { + Handlers::$wasCalled = true; + } +} + +class TestHandlerWithInvokeMethod +{ + public function __invoke(MessageCreatedUpdate $update, Api $api): void + { + Handlers::$wasCalled = true; + } +} + +class TestHandlerWithCustomMethod +{ + public function custom(MessageCreatedUpdate $update, Api $api): void + { + Handlers::$wasCalled = true; + } +}