diff --git a/src/ModelFactory.php b/src/ModelFactory.php index 3894515..b17c508 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -369,14 +369,23 @@ readonly class ModelFactory /** * Creates a specific Update model based on the 'update_type' field. * - * @param array $data Raw data for a single update. + * @param mixed $data Raw data for a single update. Anything that is not an array + * (e.g. a scalar produced by json_decode) is converted to LogicException, + * so a webhook or a long-polling loop can skip it instead of crashing. * * @return AbstractUpdate * @throws ReflectionException * @throws LogicException */ - public function createUpdate(array $data): AbstractUpdate + public function createUpdate(mixed $data): AbstractUpdate { + if (!is_array($data)) { + $message = sprintf('Failed to parse update: expected JSON object, got %s', get_debug_type($data)); + $this->logger->warning($message, ['payload' => $data]); + + throw new LogicException($message); + } + try { return match (UpdateType::tryFrom($data['update_type'] ?? '')) { UpdateType::MessageCreated => MessageCreatedUpdate::fromArray($data), diff --git a/tests/ModelFactoryTest.php b/tests/ModelFactoryTest.php index dcfd2c9..5050a2a 100644 --- a/tests/ModelFactoryTest.php +++ b/tests/ModelFactoryTest.php @@ -840,6 +840,67 @@ final class ModelFactoryTest extends TestCase $this->assertSame(7, $updateList->marker); } + public static function nonArrayPayloadProvider(): array + { + return [ + 'string' => ['123'], + 'integer' => [42], + 'null' => [null], + 'float' => [3.14], + 'boolean' => [true], + ]; + } + + #[Test] + #[DataProvider('nonArrayPayloadProvider')] + public function createUpdateTurnsNonArrayPayloadIntoLogicException(mixed $payload): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with( + 'Failed to parse update: expected JSON object, got ' . get_debug_type($payload), + ['payload' => $payload], + ); + $factory = new ModelFactory($logger); + + try { + $factory->createUpdate($payload); + $this->fail('LogicException expected'); + } catch (LogicException $e) { + $this->assertNull($e->getPrevious()); + } + } + + #[Test] + public function createUpdateListSkipsNonArrayElements(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('warning'); + $factory = new ModelFactory($logger); + + $updateList = $factory->createUpdateList([ + 'updates' => [ + 'junk', + 41, + [ + 'update_type' => 'message_created', + 'timestamp' => 3, + 'message' => [ + 'timestamp' => 3, + 'body' => ['mid' => 'mid.2', 'seq' => 2], + 'recipient' => ['chat_type' => 'dialog'], + ], + ], + ], + 'marker' => 9, + ]); + + $this->assertCount(1, $updateList->updates); + $this->assertInstanceOf(MessageCreatedUpdate::class, $updateList->updates[0]); + $this->assertSame(9, $updateList->marker); + } + #[Test] public function createUpdateListCatchesAndLogsLogicException(): void { diff --git a/tests/WebhookHandlerTest.php b/tests/WebhookHandlerTest.php index 3f2b114..309315a 100644 --- a/tests/WebhookHandlerTest.php +++ b/tests/WebhookHandlerTest.php @@ -249,4 +249,40 @@ final class WebhookHandlerTest extends TestCase $this->assertFalse($handlerWasCalled); } + + public static function nonJsonObjectBodyProvider(): array + { + return [ + 'number' => ['123'], + 'null' => ['null'], + 'string' => ['"abc"'], + 'boolean' => ['true'], + ]; + } + + #[Test] + #[DataProvider('nonJsonObjectBodyProvider')] + public function handleSkipsBodyThatIsNotAJsonObject(string $payload): void + { + $request = $this->createMockRequest($payload, self::SECRET); + + $this->loggerMock->expects($this->once()) + ->method('warning') + ->with($this->stringContains('Failed to parse update: expected JSON object')); + + $handlerWasCalled = false; + $this->dispatcher->addHandler(UpdateType::MessageCreated, function () use (&$handlerWasCalled) { + $handlerWasCalled = true; + }); + + $handler = new WebhookHandler( + $this->dispatcher, + new ModelFactory($this->loggerMock), + $this->loggerMock, + self::SECRET, + ); + $handler->handle($request); + + $this->assertFalse($handlerWasCalled); + } }