feat: max-bot-test initial — full surface coverage
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:
pkirillw
2026-06-18 00:01:06 +03:00
commit 9385fbdd6e
60 changed files with 7191 additions and 0 deletions

View 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);
}
}

View 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']);
}
}

View 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(' '));
}
}