diff --git a/src/ModelFactory.php b/src/ModelFactory.php index 9c1c08a..3894515 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -75,6 +75,8 @@ use LogicException; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use ReflectionException; +use TypeError; +use ValueError; /** * Creates DTOs from raw associative arrays returned by the API client. @@ -375,27 +377,41 @@ readonly class ModelFactory */ public function createUpdate(array $data): AbstractUpdate { - return match (UpdateType::tryFrom($data['update_type'] ?? '')) { - UpdateType::MessageCreated => MessageCreatedUpdate::fromArray($data), - UpdateType::MessageCallback => MessageCallbackUpdate::fromArray($data), - UpdateType::MessageEdited => MessageEditedUpdate::fromArray($data), - UpdateType::MessageRemoved => MessageRemovedUpdate::fromArray($data), - UpdateType::BotAdded => BotAddedToChatUpdate::fromArray($data), - UpdateType::BotRemoved => BotRemovedFromChatUpdate::fromArray($data), - UpdateType::DialogMuted => DialogMutedUpdate::fromArray($data), - UpdateType::DialogUnmuted => DialogUnmutedUpdate::fromArray($data), - UpdateType::DialogCleared => DialogClearedUpdate::fromArray($data), - UpdateType::DialogRemoved => DialogRemovedUpdate::fromArray($data), - UpdateType::UserAdded => UserAddedToChatUpdate::fromArray($data), - UpdateType::UserRemoved => UserRemovedFromChatUpdate::fromArray($data), - UpdateType::BotStarted => BotStartedUpdate::fromArray($data), - UpdateType::BotStopped => BotStoppedUpdate::fromArray($data), - UpdateType::ChatTitleChanged => ChatTitleChangedUpdate::fromArray($data), - UpdateType::MessageChatCreated => MessageChatCreatedUpdate::fromArray($data), - default => throw new LogicException( - 'Unknown or unsupported update type received: ' . ($data['update_type'] ?? 'none') - ), - }; + try { + return match (UpdateType::tryFrom($data['update_type'] ?? '')) { + UpdateType::MessageCreated => MessageCreatedUpdate::fromArray($data), + UpdateType::MessageCallback => MessageCallbackUpdate::fromArray($data), + UpdateType::MessageEdited => MessageEditedUpdate::fromArray($data), + UpdateType::MessageRemoved => MessageRemovedUpdate::fromArray($data), + UpdateType::BotAdded => BotAddedToChatUpdate::fromArray($data), + UpdateType::BotRemoved => BotRemovedFromChatUpdate::fromArray($data), + UpdateType::DialogMuted => DialogMutedUpdate::fromArray($data), + UpdateType::DialogUnmuted => DialogUnmutedUpdate::fromArray($data), + UpdateType::DialogCleared => DialogClearedUpdate::fromArray($data), + UpdateType::DialogRemoved => DialogRemovedUpdate::fromArray($data), + UpdateType::UserAdded => UserAddedToChatUpdate::fromArray($data), + UpdateType::UserRemoved => UserRemovedFromChatUpdate::fromArray($data), + UpdateType::BotStarted => BotStartedUpdate::fromArray($data), + UpdateType::BotStopped => BotStoppedUpdate::fromArray($data), + UpdateType::ChatTitleChanged => ChatTitleChangedUpdate::fromArray($data), + UpdateType::MessageChatCreated => MessageChatCreatedUpdate::fromArray($data), + default => throw new LogicException( + 'Unknown or unsupported update type received: ' . ($data['update_type'] ?? 'none') + ), + }; + } catch (TypeError|ValueError $e) { + // The update type is known, but its payload does not fit the model: the API has + // changed a field. Skip it like an unsupported update, so a webhook or a long-polling + // loop does not stop on it, but log it louder. + $message = sprintf( + 'Failed to parse update of type %s: %s', + is_string($data['update_type'] ?? null) ? $data['update_type'] : 'none', + $e->getMessage(), + ); + $this->logger->warning($message, ['payload' => $data, 'exception' => $e]); + + throw new LogicException($message, 0, $e); + } } /** diff --git a/tests/ModelFactoryTest.php b/tests/ModelFactoryTest.php index c2b1ea4..dcfd2c9 100644 --- a/tests/ModelFactoryTest.php +++ b/tests/ModelFactoryTest.php @@ -783,6 +783,63 @@ final class ModelFactoryTest extends TestCase $assertionCallback($this, $attachment); } + #[Test] + public function createUpdateTurnsPayloadThatDoesNotFitTheModelIntoLogicException(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringStartsWith('Failed to parse update of type bot_started: ')); + $factory = new ModelFactory($logger); + + try { + // `user` is required by BotStartedUpdate + $factory->createUpdate(['update_type' => 'bot_started', 'timestamp' => 1, 'chat_id' => 123]); + $this->fail('LogicException expected'); + } catch (LogicException $e) { + $this->assertInstanceOf(\TypeError::class, $e->getPrevious()); + } + } + + #[Test] + public function createUpdateListSkipsUpdatesThatDoNotFitTheirModel(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('warning'); + $factory = new ModelFactory($logger); + + $updateList = $factory->createUpdateList([ + 'updates' => [ + // TypeError: a required field is missing + ['update_type' => 'bot_started', 'timestamp' => 1, 'chat_id' => 123], + // ValueError: an enum value the library does not know yet + [ + 'update_type' => 'message_created', + 'timestamp' => 2, + 'message' => [ + 'timestamp' => 2, + 'body' => ['mid' => 'mid.1', 'seq' => 1], + 'recipient' => ['chat_type' => 'some_new_chat_type'], + ], + ], + [ + 'update_type' => 'message_created', + 'timestamp' => 3, + 'message' => [ + 'timestamp' => 3, + 'body' => ['mid' => 'mid.2', 'seq' => 2], + 'recipient' => ['chat_type' => 'dialog'], + ], + ], + ], + 'marker' => 7, + ]); + + $this->assertCount(1, $updateList->updates); + $this->assertInstanceOf(MessageCreatedUpdate::class, $updateList->updates[0]); + $this->assertSame(7, $updateList->marker); + } + #[Test] public function createUpdateListCatchesAndLogsLogicException(): void { diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php index 80352e7..3f2b114 100644 --- a/tests/WebhookHandlerTest.php +++ b/tests/WebhookHandlerTest.php @@ -40,6 +40,7 @@ use Psr\Log\LoggerInterface; #[UsesClass(Recipient::class)] #[UsesClass(AbstractUpdate::class)] #[UsesClass(MessageCreatedUpdate::class)] +#[UsesClass(ModelFactory::class)] final class WebhookHandlerTest extends TestCase { use PHPMock; @@ -226,4 +227,26 @@ final class WebhookHandlerTest extends TestCase $handler->handle($request); } + + #[Test] + public function handleSkipsUpdateThatDoesNotFitItsModel(): void + { + // `user` is required by BotStartedUpdate: before, the TypeError escaped the handler + $payload = '{"update_type":"bot_started","timestamp":123,"chat_id":1}'; + $request = $this->createMockRequest($payload, self::SECRET); + + $this->loggerMock->expects($this->once()) + ->method('warning') + ->with($this->stringStartsWith('Failed to parse update of type bot_started: ')); + + $handlerWasCalled = false; + $this->dispatcher->addHandler(UpdateType::BotStarted, function () use (&$handlerWasCalled) { + $handlerWasCalled = true; + }); + + $handler = new WebhookHandler($this->dispatcher, new ModelFactory($this->loggerMock), $this->loggerMock, self::SECRET); + $handler->handle($request); + + $this->assertFalse($handlerWasCalled); + } }