From a94dfe0df4ba6e0e5f6df701d9fe02d4a4eb6afd Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 1 Dec 2025 20:11:47 +0300 Subject: [PATCH] Silent mode for errors of unsupported event types #15 --- src/Api.php | 3 +- src/Laravel/MaxBotServiceProvider.php | 31 ++++- src/ModelFactory.php | 20 +++- src/WebhookHandler.php | 9 +- tests/ApiTest.php | 1 + tests/Laravel/MaxBotServiceProviderTest.php | 120 +++++++++++++++++++- tests/ModelFactoryTest.php | 52 +++++++++ tests/WebhookHandlerTest.php | 39 +++++++ 8 files changed, 262 insertions(+), 13 deletions(-) diff --git a/src/Api.php b/src/Api.php index 3917eff..ba65685 100644 --- a/src/Api.php +++ b/src/Api.php @@ -134,7 +134,7 @@ class Api } $this->client = $client; - $this->modelFactory = $modelFactory ?? new ModelFactory(); + $this->modelFactory = $modelFactory ?? new ModelFactory($this->logger); $this->updateDispatcher = new UpdateDispatcher($this); } @@ -685,7 +685,6 @@ class Api return null; } - return $this->modelFactory->createMessage($response['message']); } diff --git a/src/Laravel/MaxBotServiceProvider.php b/src/Laravel/MaxBotServiceProvider.php index 0c8df1f..34fc404 100644 --- a/src/Laravel/MaxBotServiceProvider.php +++ b/src/Laravel/MaxBotServiceProvider.php @@ -82,8 +82,14 @@ class MaxBotServiceProvider extends ServiceProvider ); }); - $this->app->singleton(ModelFactory::class, function () { - return new ModelFactory(); + $this->app->singleton(ModelFactory::class, function (Application $app) { + /** @var Config $config */ + $config = $app->make(Config::class); + $logger = $config->get('maxbot.logging.enabled', false) + ? $app->make(LoggerInterface::class) + : new NullLogger(); + + return new ModelFactory($logger); }); $this->app->singleton(Api::class, function (Application $app) { @@ -97,11 +103,15 @@ class MaxBotServiceProvider extends ServiceProvider ); } + $logger = $config->get('maxbot.logging.enabled', false) + ? $app->make(LoggerInterface::class) + : new NullLogger(); + return new Api( $accessToken, $app->make(ClientApiInterface::class), $app->make(ModelFactory::class), - $app->make(LoggerInterface::class), + $logger, ); }); @@ -114,19 +124,30 @@ class MaxBotServiceProvider extends ServiceProvider $config = $app->make(Config::class); $secret = $config->get('maxbot.webhook_secret'); + $logger = $config->get('maxbot.logging.enabled', false) + ? $app->make(LoggerInterface::class) + : new NullLogger(); + return new WebhookHandler( $app->make(UpdateDispatcher::class), $app->make(ModelFactory::class), - $app->make(LoggerInterface::class), + $logger, $secret, ); }); $this->app->bind(LongPollingHandler::class, function (Application $app) { + /** @var Config $config */ + $config = $app->make(Config::class); + + $logger = $config->get('maxbot.logging.enabled', false) + ? $app->make(LoggerInterface::class) + : new NullLogger(); + return new LongPollingHandler( $app->make(Api::class), $app->make(UpdateDispatcher::class), - $app->make(LoggerInterface::class), + $logger, ); }); diff --git a/src/ModelFactory.php b/src/ModelFactory.php index 79ffc2f..074021f 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -67,13 +67,25 @@ use BushlanovDev\MaxMessengerBot\Models\Updates\UserRemovedFromChatUpdate; use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint; use BushlanovDev\MaxMessengerBot\Models\VideoAttachmentDetails; use LogicException; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use ReflectionException; /** * Creates DTOs from raw associative arrays returned by the API client. */ -class ModelFactory +readonly class ModelFactory { + private LoggerInterface $logger; + + /** + * @param LoggerInterface|null $logger PSR LoggerInterface. + */ + public function __construct(?LoggerInterface $logger = null) + { + $this->logger = $logger ?? new NullLogger(); + } + /** * Simple response to request. * @@ -341,7 +353,11 @@ class ModelFactory if (isset($data['updates']) && is_array($data['updates'])) { foreach ($data['updates'] as $updateData) { // Here we delegate the creation of a specific update to another factory method - $updateObjects[] = $this->createUpdate($updateData); + try { + $updateObjects[] = $this->createUpdate($updateData); + } catch (LogicException $e) { + $this->logger->debug($e->getMessage(), ['payload' => $updateData, 'exception' => $e]); + } } } diff --git a/src/WebhookHandler.php b/src/WebhookHandler.php index 7f221c5..aa93cea 100644 --- a/src/WebhookHandler.php +++ b/src/WebhookHandler.php @@ -69,9 +69,12 @@ final readonly class WebhookHandler throw new SerializationException('Failed to decode webhook body as JSON.', 0, $e); } - $update = $this->modelFactory->createUpdate($data); - - $this->dispatcher->dispatch($update); + try { + $update = $this->modelFactory->createUpdate($data); + $this->dispatcher->dispatch($update); + } catch (\LogicException $e) { + $this->logger->debug($e->getMessage(), ['payload' => $payload, 'exception' => $e]); + } if (!headers_sent()) { http_response_code(200); diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 2b70d47..eca9644 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -118,6 +118,7 @@ use RuntimeException; #[UsesClass(VideoAttachmentDetails::class)] #[UsesClass(VideoUrls::class)] #[UsesClass(UpdateDispatcher::class)] +#[UsesClass(ModelFactory::class)] final class ApiTest extends TestCase { use PHPMock; diff --git a/tests/Laravel/MaxBotServiceProviderTest.php b/tests/Laravel/MaxBotServiceProviderTest.php index e878823..c74107c 100644 --- a/tests/Laravel/MaxBotServiceProviderTest.php +++ b/tests/Laravel/MaxBotServiceProviderTest.php @@ -38,6 +38,7 @@ use ReflectionClass; #[UsesClass(UpdateDispatcher::class)] #[UsesClass(MaxBotManager::class)] #[UsesClass(WebhookHandler::class)] +#[UsesClass(ModelFactory::class)] final class MaxBotServiceProviderTest extends TestCase { use PHPMock; @@ -212,6 +213,105 @@ final class MaxBotServiceProviderTest extends TestCase $this->assertInstanceOf(NullLogger::class, $actualLogger); } + #[Test] + public function modelFactoryIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void + { + $this->app['config']->set('maxbot.logging.enabled', true); + + $mockLogger = $this->createMock(LoggerInterface::class); + $this->app->instance(LoggerInterface::class, $mockLogger); + + /** @var ModelFactory $factory */ + $factory = $this->app->make(ModelFactory::class); + + $reflection = new ReflectionClass($factory); + $loggerProp = $reflection->getProperty('logger'); + $actualLogger = $loggerProp->getValue($factory); + + $this->assertSame($mockLogger, $actualLogger); + } + + #[Test] + public function modelFactoryIsConfiguredWithNullLoggerWhenLoggingIsDisabled(): void + { + $this->app['config']->set('maxbot.logging.enabled', false); + + /** @var ModelFactory $factory */ + $factory = $this->app->make(ModelFactory::class); + + $reflection = new ReflectionClass($factory); + $loggerProp = $reflection->getProperty('logger'); + $actualLogger = $loggerProp->getValue($factory); + + $this->assertInstanceOf(NullLogger::class, $actualLogger); + } + + #[Test] + public function webhookHandlerIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void + { + $this->app['config']->set('maxbot.logging.enabled', true); + + $mockLogger = $this->createMock(LoggerInterface::class); + $this->app->instance(LoggerInterface::class, $mockLogger); + + /** @var WebhookHandler $handler */ + $handler = $this->app->make(WebhookHandler::class); + + $reflection = new ReflectionClass($handler); + $loggerProp = $reflection->getProperty('logger'); + $actualLogger = $loggerProp->getValue($handler); + + $this->assertSame($mockLogger, $actualLogger); + } + + #[Test] + public function webhookHandlerIsConfiguredWithNullLoggerWhenLoggingIsDisabled(): void + { + $this->app['config']->set('maxbot.logging.enabled', false); + + /** @var WebhookHandler $handler */ + $handler = $this->app->make(WebhookHandler::class); + + $reflection = new ReflectionClass($handler); + $loggerProp = $reflection->getProperty('logger'); + $actualLogger = $loggerProp->getValue($handler); + + $this->assertInstanceOf(NullLogger::class, $actualLogger); + } + + #[Test] + public function longPollingHandlerIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void + { + $this->app['config']->set('maxbot.logging.enabled', true); + + $mockLogger = $this->createMock(LoggerInterface::class); + $this->app->instance(LoggerInterface::class, $mockLogger); + + /** @var LongPollingHandler $handler */ + $handler = $this->app->make(LongPollingHandler::class); + + $reflection = new ReflectionClass($handler); + $loggerProp = $reflection->getProperty('logger'); + $actualLogger = $loggerProp->getValue($handler); + + $this->assertSame($mockLogger, $actualLogger); + } + + #[Test] + public function longPollingHandlerIsConfiguredWithNullLoggerWhenLoggingIsDisabled(): void + { + $this->app['config']->set('maxbot.logging.enabled', false); + + /** @var LongPollingHandler $handler */ + $handler = $this->app->make(LongPollingHandler::class); + + $reflection = new ReflectionClass($handler); + $loggerProp = $reflection->getProperty('logger'); + $actualLogger = $loggerProp->getValue($handler); + + $this->assertInstanceOf(NullLogger::class, $actualLogger); + } + #[Test] public function webhookHandlerIsConfiguredWithSecretFromConfig(): void { @@ -240,10 +340,28 @@ final class MaxBotServiceProviderTest extends TestCase $this->assertSame($this->app->make(ClientApiInterface::class), $clientProp->getValue($api)); $this->assertSame($this->app->make(ModelFactory::class), $factoryProp->getValue($api)); - $this->assertSame($this->app->make(LoggerInterface::class), $loggerProp->getValue($api)); + $this->assertInstanceOf(NullLogger::class, $loggerProp->getValue($api)); $this->assertSame($this->app->make(UpdateDispatcher::class), $dispatcherProp->getValue($api)); } + #[Test] + public function apiIsConfiguredWithApplicationLoggerWhenLoggingIsEnabled(): void + { + $this->app['config']->set('maxbot.logging.enabled', true); + + $mockLogger = $this->createMock(LoggerInterface::class); + $this->app->instance(LoggerInterface::class, $mockLogger); + + /** @var Api $api */ + $api = $this->app->make(Api::class); + + $reflection = new ReflectionClass($api); + $loggerProp = $reflection->getProperty('logger'); + $actualLogger = $loggerProp->getValue($api); + + $this->assertSame($mockLogger, $actualLogger); + } + /** * @return array */ diff --git a/tests/ModelFactoryTest.php b/tests/ModelFactoryTest.php index 1f4859c..84f819d 100644 --- a/tests/ModelFactoryTest.php +++ b/tests/ModelFactoryTest.php @@ -59,6 +59,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; #[CoversClass(ModelFactory::class)] #[UsesClass(BotInfo::class)] @@ -818,4 +819,55 @@ final class ModelFactoryTest extends TestCase $assertionCallback($this, $attachment); } + + #[Test] + public function createUpdateListCatchesAndLogsLogicException(): void + { + $loggerMock = $this->createMock(LoggerInterface::class); + $factory = $this->getMockBuilder(ModelFactory::class) + ->setConstructorArgs([$loggerMock]) + ->onlyMethods(['createUpdate']) + ->getMock(); + + $validUpdateData = [ + 'update_type' => 'bot_started', + 'timestamp' => 2, + 'chat_id' => 123, + 'user' => [ + 'user_id' => 123, + 'first_name' => 'John', + 'is_bot' => false, + 'last_activity_time' => 2, + ], + 'payload' => 'start_payload', + 'user_locale' => 'ru-RU', + ]; + $invalidUpdateData = ['update_type' => 'unknown_type']; + $rawData = [ + 'updates' => [$validUpdateData, $invalidUpdateData], + 'marker' => 123, + ]; + + $exception = new LogicException('Unknown or unsupported update type received: unknown_type'); + $factory->expects($this->exactly(2)) + ->method('createUpdate') + ->willReturnCallback(function ($data) use ($validUpdateData, $invalidUpdateData, $exception) { + if ($data === $validUpdateData) { + return BotStartedUpdate::fromArray($data); + } + if ($data === $invalidUpdateData) { + throw $exception; + } + return null; + }); + + $loggerMock->expects($this->once()) + ->method('debug') + ->with($exception->getMessage(), ['payload' => $invalidUpdateData, 'exception' => $exception]); + + $updateList = $factory->createUpdateList($rawData); + + $this->assertCount(1, $updateList->updates); + $this->assertInstanceOf(BotStartedUpdate::class, $updateList->updates[0]); + } } diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php index 48eae25..b883653 100644 --- a/tests/WebhookHandlerTest.php +++ b/tests/WebhookHandlerTest.php @@ -185,4 +185,43 @@ final class WebhookHandlerTest extends TestCase $handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, null); $handler->handle(null); } + + #[Test] + public function handleCatchesAndLogsLogicExceptionFromModelFactory(): void + { + $payload = '{"update_type":"unknown_type","timestamp":123}'; + $updateData = json_decode($payload, true); + $exception = new LogicException('Unknown or unsupported update type received: unknown_type'); + + $request = $this->createMockRequest($payload, self::SECRET); + + $this->modelFactoryMock->expects($this->once()) + ->method('createUpdate') + ->with($updateData) + ->willThrowException($exception); + + $callIndex = 0; + $this->loggerMock->expects($this->exactly(2)) + ->method('debug') + ->willReturnCallback( + function (string $message, array $context = []) use (&$callIndex, $payload, $exception) { + if ($callIndex === 0) { + $this->assertSame('Received webhook payload', $message); + $this->assertArrayHasKey('body', $context); + $this->assertSame($payload, $context['body']); + } elseif ($callIndex === 1) { + $this->assertSame('Unknown or unsupported update type received: unknown_type', $message); + $this->assertArrayHasKey('payload', $context); + $this->assertArrayHasKey('exception', $context); + $this->assertSame($payload, $context['payload']); + $this->assertSame($exception, $context['exception']); + } + $callIndex++; + } + ); + + $handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET); + + $handler->handle($request); + } }