diff --git a/src/ModelFactory.php b/src/ModelFactory.php index 0fcacf7..b6b7b86 100644 --- a/src/ModelFactory.php +++ b/src/ModelFactory.php @@ -137,7 +137,7 @@ class ModelFactory return new UpdateList( $updateObjects, - $data['marker'] ?? null, + $data['marker'] ? (int)$data['marker'] : null, ); } diff --git a/src/Models/UpdateList.php b/src/Models/UpdateList.php index 2f17390..c0fb06f 100644 --- a/src/Models/UpdateList.php +++ b/src/Models/UpdateList.php @@ -20,4 +20,18 @@ final readonly class UpdateList extends AbstractModel public ?int $marker, ) { } + + /** + * Overridden to prevent incorrect usage. + * UpdateList contains polymorphic objects and must be created via ModelFactory. + * + * @param array $data + * @throws \LogicException Always. + */ + public static function fromArray(array $data): static + { + throw new \LogicException( + 'Cannot create UpdateList directly from an array. Use ModelFactory::createUpdateList() instead.' + ); + } } diff --git a/src/WebhookHandler.php b/src/WebhookHandler.php index c91db58..ce65522 100644 --- a/src/WebhookHandler.php +++ b/src/WebhookHandler.php @@ -68,6 +68,7 @@ final class WebhookHandler * @param callable(Models\Updates\BotStartedUpdate, Api): void $handler * * @return $this + * @codeCoverageIgnore */ public function onBotStarted(callable $handler): self { diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 76b82ac..5523e85 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -29,6 +29,7 @@ use BushlanovDev\MaxMessengerBot\Models\Sender; use BushlanovDev\MaxMessengerBot\Models\Subscription; use BushlanovDev\MaxMessengerBot\Models\UpdateList; use BushlanovDev\MaxMessengerBot\Models\UploadEndpoint; +use BushlanovDev\MaxMessengerBot\WebhookHandler; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -56,6 +57,7 @@ use ReflectionClass; #[UsesClass(UploadEndpoint::class)] #[UsesClass(Chat::class)] #[UsesClass(UpdateList::class)] +#[UsesClass(WebhookHandler::class)] final class ApiTest extends TestCase { private MockObject&ClientApiInterface $clientMock; @@ -546,4 +548,24 @@ 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)); + } } diff --git a/tests/Models/Attachments/Buttons/OpenAppButtonTest.php b/tests/Models/Attachments/Buttons/OpenAppButtonTest.php index aa96ebf..6e288df 100644 --- a/tests/Models/Attachments/Buttons/OpenAppButtonTest.php +++ b/tests/Models/Attachments/Buttons/OpenAppButtonTest.php @@ -11,7 +11,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; #[CoversClass(OpenAppButton::class)] -class OpenAppButtonTest extends TestCase +final class OpenAppButtonTest extends TestCase { #[Test] public function toArraySerializesCorrectly(): void diff --git a/tests/Models/Attachments/Payloads/InlineKeyboardPayloadTest.php b/tests/Models/Attachments/Payloads/InlineKeyboardPayloadTest.php new file mode 100644 index 0000000..0f1b764 --- /dev/null +++ b/tests/Models/Attachments/Payloads/InlineKeyboardPayloadTest.php @@ -0,0 +1,88 @@ +assertInstanceOf(InlineKeyboardPayload::class, $payload); + $this->assertSame($buttons, $payload->buttons); + } + + #[Test] + public function toArraySerializesCorrectly(): void + { + $buttons = [ + [new CallbackButton('Accept', 'accept_payload', Intent::Positive)], + [ + new CallbackButton('Decline', 'decline_payload', Intent::Negative), + new LinkButton('Help', 'https://example.com/help'), + ], + ]; + $payload = new InlineKeyboardPayload($buttons); + + $resultArray = $payload->toArray(); + + $expectedArray = [ + 'buttons' => [ + [ + [ + 'type' => ButtonType::Callback->value, + 'text' => 'Accept', + 'payload' => 'accept_payload', + 'intent' => Intent::Positive->value, + ], + ], + [ + [ + 'type' => ButtonType::Callback->value, + 'text' => 'Decline', + 'payload' => 'decline_payload', + 'intent' => Intent::Negative->value, + ], + [ + 'type' => ButtonType::Link->value, + 'text' => 'Help', + 'url' => 'https://example.com/help', + ], + ], + ], + ]; + + $this->assertEquals($expectedArray, $resultArray); + } + + #[Test] + public function toArrayHandlesEmptyButtonsArray(): void + { + $payload = new InlineKeyboardPayload([]); + $resultArray = $payload->toArray(); + + $expectedArray = [ + 'buttons' => [], + ]; + $this->assertEquals($expectedArray, $resultArray); + } +} diff --git a/tests/Models/Attachments/Payloads/PhotoAttachmentPayloadTest.php b/tests/Models/Attachments/Payloads/PhotoAttachmentPayloadTest.php new file mode 100644 index 0000000..cd24af6 --- /dev/null +++ b/tests/Models/Attachments/Payloads/PhotoAttachmentPayloadTest.php @@ -0,0 +1,108 @@ +assertSame('https://example.com/photo.jpg', $payload->url); + $this->assertNull($payload->token); + $this->assertNull($payload->photos); + + $expectedArray = [ + 'url' => 'https://example.com/photo.jpg', + 'token' => null, + 'photos' => null, + ]; + $this->assertEquals($expectedArray, $payload->toArray()); + } + + #[Test] + public function canBeCreatedWithTokenOnly(): void + { + $payload = new PhotoAttachmentPayload(token: 'uploaded_token_abc'); + + $this->assertSame('uploaded_token_abc', $payload->token); + $this->assertNull($payload->url); + $this->assertNull($payload->photos); + + $expectedArray = [ + 'token' => 'uploaded_token_abc', + 'url' => null, + 'photos' => null, + ]; + $this->assertEquals($expectedArray, $payload->toArray()); + } + + #[Test] + public function canBeCreatedWithPhotosOnly(): void + { + $photos = [ + new PhotoToken('token_1'), + new PhotoToken('token_2'), + ]; + $payload = new PhotoAttachmentPayload(photos: $photos); + + $this->assertSame($photos, $payload->photos); + $this->assertNull($payload->url); + $this->assertNull($payload->token); + + $expectedArray = [ + 'photos' => [ + ['token' => 'token_1'], + ['token' => 'token_2'], + ], + 'url' => null, + 'token' => null, + ]; + $this->assertEquals($expectedArray, $payload->toArray()); + } + + /** + * Data provider for invalid constructor arguments. + * + * @return array + */ + public static function invalidPayloadProvider(): array + { + return [ + 'all null (no arguments)' => [null, null, null], + 'url and token provided' => ['https://a.com', 'token123', null], + 'url and photos provided' => ['https://a.com', null, [new PhotoToken('t')]], + 'token and photos provided' => [null, 'token123', [new PhotoToken('t')]], + 'all three arguments provided' => ['https://a.com', 'token123', [new PhotoToken('t')]], + ]; + } + + #[Test] + #[DataProvider('invalidPayloadProvider')] + public function constructorThrowsExceptionForInvalidArguments( + ?string $url, + ?string $token, + ?array $photos + ): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provide exactly one of "url", "token", or "photos" for PhotoAttachmentPayload.'); + + new PhotoAttachmentPayload($url, $token, $photos); + } +} diff --git a/tests/Models/Attachments/Payloads/PhotoTokenTest.php b/tests/Models/Attachments/Payloads/PhotoTokenTest.php new file mode 100644 index 0000000..18642e5 --- /dev/null +++ b/tests/Models/Attachments/Payloads/PhotoTokenTest.php @@ -0,0 +1,32 @@ + $tokenValue]; + + $photoToken = PhotoToken::fromArray($rawData); + + $this->assertInstanceOf(PhotoToken::class, $photoToken); + $this->assertSame($tokenValue, $photoToken->token); + + $serializedData = $photoToken->toArray(); + $this->assertEquals($rawData, $serializedData); + + $directInstance = new PhotoToken($tokenValue); + $this->assertSame($tokenValue, $directInstance->token); + } +} diff --git a/tests/Models/Attachments/Requests/InlineKeyboardAttachmentRequestTest.php b/tests/Models/Attachments/Requests/InlineKeyboardAttachmentRequestTest.php new file mode 100644 index 0000000..f2d0f0f --- /dev/null +++ b/tests/Models/Attachments/Requests/InlineKeyboardAttachmentRequestTest.php @@ -0,0 +1,127 @@ +assertInstanceOf(InlineKeyboardAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::InlineKeyboard, $request->type); + $this->assertInstanceOf(InlineKeyboardPayload::class, $request->payload); + $this->assertSame($buttons, $request->payload->buttons); + + $expectedArray = [ + 'type' => 'inline_keyboard', + 'payload' => [ + 'buttons' => [ + [ + [ + 'type' => ButtonType::Callback->value, + 'text' => 'Press Me', + 'payload' => 'cb_payload_1', + 'intent' => null, + ], + ], + [ + [ + 'type' => ButtonType::Link->value, + 'text' => 'Docs', + 'url' => 'https://dev.max.ru', + ], + ], + ], + ], + ]; + + $this->assertEquals($expectedArray, $request->toArray()); + } + + #[Test] + public function handlesJaggedAndMultiButtonRows(): void + { + $buttons = [ + [new CallbackButton('Positive', 'ok', Intent::Positive)], + [ + new CallbackButton('Negative', 'no', Intent::Negative), + new LinkButton('Help', 'https://example.com/help'), + ], + ]; + $request = new InlineKeyboardAttachmentRequest($buttons); + + $expectedArray = [ + 'type' => 'inline_keyboard', + 'payload' => [ + 'buttons' => [ + [ + [ + 'type' => ButtonType::Callback->value, + 'text' => 'Positive', + 'payload' => 'ok', + 'intent' => Intent::Positive->value, + ], + ], + [ + [ + 'type' => ButtonType::Callback->value, + 'text' => 'Negative', + 'payload' => 'no', + 'intent' => Intent::Negative->value, + ], + [ + 'type' => ButtonType::Link->value, + 'text' => 'Help', + 'url' => 'https://example.com/help', + ], + ], + ], + ], + ]; + + $this->assertEquals($expectedArray, $request->toArray()); + } + + #[Test] + public function canBeCreatedWithEmptyButtonsArray(): void + { + $buttons = []; + $request = new InlineKeyboardAttachmentRequest($buttons); + + $this->assertEmpty($request->payload->buttons); + + $expectedArray = [ + 'type' => 'inline_keyboard', + 'payload' => [ + 'buttons' => $buttons, + ], + ]; + $this->assertEquals($expectedArray, $request->toArray()); + } +} diff --git a/tests/Models/Attachments/Requests/PhotoAttachmentRequestTest.php b/tests/Models/Attachments/Requests/PhotoAttachmentRequestTest.php new file mode 100644 index 0000000..dfbc5c4 --- /dev/null +++ b/tests/Models/Attachments/Requests/PhotoAttachmentRequestTest.php @@ -0,0 +1,126 @@ +assertInstanceOf(PhotoAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::Image, $request->type); + $this->assertInstanceOf(PhotoAttachmentPayload::class, $request->payload); + $this->assertSame($url, $request->payload->url); + $this->assertNull($request->payload->token); + $this->assertNull($request->payload->photos); + + $expectedArray = [ + 'type' => 'image', + 'payload' => [ + 'url' => $url, + 'token' => null, + 'photos' => null, + ], + ]; + $this->assertEquals($expectedArray, $request->toArray()); + } + + #[Test] + public function testFromToken(): void + { + $token = 'some_upload_token_12345'; + $request = PhotoAttachmentRequest::fromToken($token); + + $this->assertInstanceOf(PhotoAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::Image, $request->type); + $this->assertInstanceOf(PhotoAttachmentPayload::class, $request->payload); + $this->assertSame($token, $request->payload->token); + $this->assertNull($request->payload->url); + $this->assertNull($request->payload->photos); + + $expectedArray = [ + 'type' => 'image', + 'payload' => [ + 'token' => $token, + 'url' => null, + 'photos' => null, + ], + ]; + $this->assertEquals($expectedArray, $request->toArray()); + } + + #[Test] + public function testFromPhotos(): void + { + $photos = [ + new PhotoToken('token_A'), + new PhotoToken('token_B'), + ]; + $request = PhotoAttachmentRequest::fromPhotos($photos); + + $this->assertInstanceOf(PhotoAttachmentRequest::class, $request); + $this->assertSame(AttachmentType::Image, $request->type); + $this->assertInstanceOf(PhotoAttachmentPayload::class, $request->payload); + $this->assertSame($photos, $request->payload->photos); + $this->assertNull($request->payload->url); + $this->assertNull($request->payload->token); + + $expectedArray = [ + 'type' => 'image', + 'payload' => [ + 'photos' => [ + ['token' => 'token_A'], + ['token' => 'token_B'], + ], + 'url' => null, + 'token' => null, + ], + ]; + $this->assertEquals($expectedArray, $request->toArray()); + } + + /** + * @return array + */ + public static function invalidPayloadProvider(): array + { + return [ + 'no arguments' => [null, null, null], + 'url and token' => ['http://a.com', 'token123', null], + 'token and photos' => [null, 'token123', [new PhotoToken('t')]], + 'all arguments' => ['http://a.com', 'token123', [new PhotoToken('t')]], + ]; + } + + #[Test] + #[DataProvider('invalidPayloadProvider')] + public function payloadThrowsExceptionWhenNotExactlyOneArgumentIsProvided( + ?string $url, + ?string $token, + ?array $photos + ): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provide exactly one of "url", "token", or "photos" for PhotoAttachmentPayload.'); + + new PhotoAttachmentPayload($url, $token, $photos); + } +} diff --git a/tests/Models/UpdateListTest.php b/tests/Models/UpdateListTest.php new file mode 100644 index 0000000..8129272 --- /dev/null +++ b/tests/Models/UpdateListTest.php @@ -0,0 +1,64 @@ + [ + [ + 'update_type' => UpdateType::MessageCreated->value, + 'timestamp' => 1678886400000, + 'message' => [ + 'timestamp' => 1678886400000, + 'body' => ['mid' => 'mid.123', 'seq' => 1, 'text' => 'Hello'], + 'recipient' => ['chat_type' => 'dialog', 'user_id' => 123], + ], + 'user_locale' => 'ru-RU', + ], + ], + 'marker' => 1, + ]; + + $factory = new ModelFactory(); + $updateList = $factory->createUpdateList($data); + + $this->assertInstanceOf(UpdateList::class, $updateList); + $this->assertCount(1, $updateList->updates); + $this->assertInstanceOf(MessageCreatedUpdate::class, $updateList->updates[0]); + $this->assertSame(1, $updateList->marker); + } + + #[Test] + public function directCallToFromArrayThrowsException(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/Cannot create .* directly from an array/'); + + UpdateList::fromArray(['updates' => [], 'marker' => 1]); + } +} diff --git a/tests/Models/Updates/BotStartedUpdateTest.php b/tests/Models/Updates/BotStartedUpdateTest.php new file mode 100644 index 0000000..979ae99 --- /dev/null +++ b/tests/Models/Updates/BotStartedUpdateTest.php @@ -0,0 +1,47 @@ + UpdateType::BotStarted->value, + 'timestamp' => 1678886400000, + 'chat_id' => 123, + 'user' => [ + 'user_id' => 123, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'is_bot' => false, + 'last_activity_time' => 1678886400000, + 'avatar_url' => 'https://example.com/avatar.jpg', + ], + 'user_locale' => 'ru-ru', + ]; + + $update = BotStartedUpdate::fromArray($data); + + $this->assertInstanceOf(BotStartedUpdate::class, $update); + $this->assertSame(UpdateType::BotStarted, $update->updateType); + $this->assertSame(123, $update->user->userId); + $this->assertSame('John', $update->user->firstName); + $this->assertSame('Doe', $update->user->lastName); + $this->assertSame('https://example.com/avatar.jpg', $update->user->avatarUrl); + $this->assertSame('ru-ru', $update->userLocale); + } +} diff --git a/tests/Models/Updates/MessageCreatedUpdateTest.php b/tests/Models/Updates/MessageCreatedUpdateTest.php index 4e3eccc..cc6cd2d 100644 --- a/tests/Models/Updates/MessageCreatedUpdateTest.php +++ b/tests/Models/Updates/MessageCreatedUpdateTest.php @@ -18,7 +18,7 @@ use PHPUnit\Framework\TestCase; #[UsesClass(Message::class)] #[UsesClass(MessageBody::class)] #[UsesClass(Recipient::class)] -class MessageCreatedUpdateTest extends TestCase +final class MessageCreatedUpdateTest extends TestCase { #[Test] public function canBeCreatedFromArray(): void