feat: max-bot-test initial — full surface coverage
Some checks failed
Deploy Max Bot Test / deploy (push) Failing after 3s
Some checks failed
Deploy Max Bot Test / deploy (push) Failing after 3s
- Slim 4 PSR-15 webhook, PHP-DI 7, Symfony Console 8 - 13 update handlers, 30+ command scenarios, all 7 button types - Composer pulls pkirillw/max-bot-api-php from GitHub vcs - Multi-stage Dockerfile (php-fpm 8.4-alpine) + nginx - docker-compose.yml targets server (npm_default external) - docker-compose.override.yml for local dev (gitignored) - .gitea/workflows/deploy.yml mirrors ghost-blog-bot pattern Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
85
tests/Integration/WebhookSecretTest.php
Normal file
85
tests/Integration/WebhookSecretTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Integration;
|
||||
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Nyholm\Psr7\ServerRequest;
|
||||
use Pkirillw\MaxBotApi\Webhook\WebhookHandler;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class WebhookSecretTest extends TestCase
|
||||
{
|
||||
private const SECRET = 'topsecret';
|
||||
|
||||
public function testRejectsMissingSecretWith401(): void
|
||||
{
|
||||
$handler = $this->makeHandler();
|
||||
$request = new ServerRequest('POST', '/webhook', [], $this->messageCreatedBody());
|
||||
$response = $handler->handle($request);
|
||||
self::assertSame(401, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testRejectsWrongSecretWith401(): void
|
||||
{
|
||||
$handler = $this->makeHandler();
|
||||
$request = (new ServerRequest('POST', '/webhook', [], $this->messageCreatedBody()))
|
||||
->withHeader('X-Max-Bot-Api-Secret', 'wrong');
|
||||
$response = $handler->handle($request);
|
||||
self::assertSame(401, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAcceptsValidSecretAndInvokesUserlandHandler(): void
|
||||
{
|
||||
$handler = $this->makeHandler();
|
||||
$received = null;
|
||||
$handler->setHandler(static function (object $update) use (&$received): void {
|
||||
$received = $update;
|
||||
});
|
||||
|
||||
$request = (new ServerRequest('POST', '/webhook', [], $this->messageCreatedBody()))
|
||||
->withHeader('X-Max-Bot-Api-Secret', self::SECRET);
|
||||
$response = $handler->handle($request);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertNotNull($received);
|
||||
self::assertSame('message_created', $received->getUpdateType()->value);
|
||||
}
|
||||
|
||||
public function testReturns400OnEmptyBody(): void
|
||||
{
|
||||
$handler = $this->makeHandler();
|
||||
$request = (new ServerRequest('POST', '/webhook'))
|
||||
->withHeader('X-Max-Bot-Api-Secret', self::SECRET);
|
||||
$response = $handler->handle($request);
|
||||
self::assertSame(400, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testReturns405OnGet(): void
|
||||
{
|
||||
$handler = $this->makeHandler();
|
||||
$request = (new ServerRequest('GET', '/webhook'))
|
||||
->withHeader('X-Max-Bot-Api-Secret', self::SECRET);
|
||||
$response = $handler->handle($request);
|
||||
self::assertSame(405, $response->getStatusCode());
|
||||
}
|
||||
|
||||
private function makeHandler(): WebhookHandler
|
||||
{
|
||||
return new WebhookHandler(new Psr17Factory(), secret: self::SECRET, keepRawUpdates: true);
|
||||
}
|
||||
|
||||
private function messageCreatedBody(): string
|
||||
{
|
||||
return json_encode([
|
||||
'update_type' => 'message_created',
|
||||
'timestamp' => 1_700_000_000_000,
|
||||
'message' => [
|
||||
'body' => ['mid' => 'mid-1', 'seq' => 1, 'text' => '/help'],
|
||||
'recipient' => ['chat_id' => 100, 'chat_type' => 'dialog'],
|
||||
'sender' => ['user_id' => 200, 'name' => 'Tester'],
|
||||
],
|
||||
], JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
93
tests/Unit/KeyboardFactoryTest.php
Normal file
93
tests/Unit/KeyboardFactoryTest.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\Keyboard\KeyboardFactory;
|
||||
use Pkirillw\MaxBotApi\Scheme\Button\CallbackButton;
|
||||
use Pkirillw\MaxBotApi\Scheme\Button\ClipboardButton;
|
||||
use Pkirillw\MaxBotApi\Scheme\Button\LinkButton;
|
||||
use Pkirillw\MaxBotApi\Scheme\Button\MessageButton;
|
||||
use Pkirillw\MaxBotApi\Scheme\Button\OpenAppButton;
|
||||
use Pkirillw\MaxBotApi\Scheme\Button\RequestContactButton;
|
||||
use Pkirillw\MaxBotApi\Scheme\Button\RequestGeoLocationButton;
|
||||
use Pkirillw\MaxBotApi\Scheme\Enum\Intent;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class KeyboardFactoryTest extends TestCase
|
||||
{
|
||||
public function testFullKeyboardCoversAllButtonTypes(): void
|
||||
{
|
||||
$kb = (new KeyboardFactory('https://app.example.com/web'))->full();
|
||||
$rows = $kb->getRows();
|
||||
self::assertCount(7, $rows, 'full keyboard has 7 rows when WEB_APP_URL is set');
|
||||
|
||||
// Flatten buttons.
|
||||
$buttons = [];
|
||||
foreach ($rows as $row) {
|
||||
foreach ($row->getButtons() as $button) {
|
||||
$buttons[] = $button;
|
||||
}
|
||||
}
|
||||
|
||||
$types = array_map(fn($b) => $b->getType()->value, $buttons);
|
||||
self::assertContains('callback', $types);
|
||||
self::assertContains('link', $types);
|
||||
self::assertContains('message', $types);
|
||||
self::assertContains('clipboard', $types);
|
||||
self::assertContains('request_contact', $types);
|
||||
self::assertContains('request_geo_location', $types);
|
||||
self::assertContains('open_app', $types);
|
||||
}
|
||||
|
||||
public function testFullKeyboardSkipsOpenAppWhenUrlMissing(): void
|
||||
{
|
||||
$kb = (new KeyboardFactory(null))->full();
|
||||
$rows = $kb->getRows();
|
||||
self::assertCount(6, $rows, 'OpenApp row is skipped without WEB_APP_URL');
|
||||
|
||||
$buttons = [];
|
||||
foreach ($rows as $row) {
|
||||
foreach ($row->getButtons() as $button) {
|
||||
$buttons[] = $button;
|
||||
}
|
||||
}
|
||||
$types = array_map(fn($b) => $b->getType()->value, $buttons);
|
||||
self::assertNotContains('open_app', $types);
|
||||
}
|
||||
|
||||
public function testCallbackRowCoversAllIntents(): void
|
||||
{
|
||||
$kb = (new KeyboardFactory())->full();
|
||||
$callbackRow = $kb->getRows()[0];
|
||||
$buttons = $callbackRow->getButtons();
|
||||
|
||||
self::assertCount(3, $buttons);
|
||||
self::assertInstanceOf(CallbackButton::class, $buttons[0]);
|
||||
self::assertSame(Intent::Positive->value, $buttons[0]->intent->value);
|
||||
self::assertSame(Intent::Negative->value, $buttons[1]->intent->value);
|
||||
self::assertSame(Intent::Default->value, $buttons[2]->intent->value);
|
||||
}
|
||||
|
||||
public function testJsonSerializationShape(): void
|
||||
{
|
||||
$kb = (new KeyboardFactory())->simpleCallback();
|
||||
$rows = $kb->getRows();
|
||||
|
||||
self::assertCount(1, $rows);
|
||||
$buttons = $rows[0]->getButtons();
|
||||
self::assertCount(2, $buttons);
|
||||
|
||||
$first = $buttons[0];
|
||||
self::assertInstanceOf(CallbackButton::class, $first);
|
||||
self::assertSame('vote:yes', $first->payload);
|
||||
|
||||
$json = $kb->build()->jsonSerialize();
|
||||
self::assertArrayHasKey('buttons', $json);
|
||||
self::assertCount(1, $json['buttons']);
|
||||
// Each row serializes as a list of {type, text, ...} objects.
|
||||
self::assertSame('vote:yes', $json['buttons'][0][0]['payload']);
|
||||
self::assertSame('vote:no', $json['buttons'][0][1]['payload']);
|
||||
}
|
||||
}
|
||||
45
tests/Unit/ParsedCommandTest.php
Normal file
45
tests/Unit/ParsedCommandTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit;
|
||||
|
||||
use App\Util\ParsedCommand;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ParsedCommandTest extends TestCase
|
||||
{
|
||||
public function testParseSimpleCommand(): void
|
||||
{
|
||||
$cmd = ParsedCommand::parse('/help');
|
||||
self::assertNotNull($cmd);
|
||||
self::assertSame('help', $cmd->name);
|
||||
self::assertSame([], $cmd->args);
|
||||
self::assertSame('', $cmd->tail);
|
||||
}
|
||||
|
||||
public function testParseCommandWithArgs(): void
|
||||
{
|
||||
$cmd = ParsedCommand::parse('/chat 123');
|
||||
self::assertNotNull($cmd);
|
||||
self::assertSame('chat', $cmd->name);
|
||||
self::assertSame(['123'], $cmd->args);
|
||||
self::assertSame('123', $cmd->tail);
|
||||
self::assertSame(123, $cmd->argInt(0));
|
||||
}
|
||||
|
||||
public function testParseColonSyntaxExtractsParam(): void
|
||||
{
|
||||
$cmd = ParsedCommand::parse('/bot_patch:NewName');
|
||||
self::assertNotNull($cmd);
|
||||
self::assertSame('bot_patch', $cmd->name);
|
||||
self::assertSame(['NewName'], $cmd->args);
|
||||
}
|
||||
|
||||
public function testParseReturnsNullForPlainText(): void
|
||||
{
|
||||
self::assertNull(ParsedCommand::parse('hello'));
|
||||
self::assertNull(ParsedCommand::parse(''));
|
||||
self::assertNull(ParsedCommand::parse(' '));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user