mirror of
https://github.com/BushlanovDev/max-bot-api-client-php.git
synced 2026-08-24 12:06:50 +00:00
Refactoring update dispatchers
This commit is contained in:
+84
-181
@@ -6,6 +6,7 @@ namespace BushlanovDev\MaxMessengerBot\Tests;
|
||||
|
||||
use BushlanovDev\MaxMessengerBot\Api;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\ChatType;
|
||||
use BushlanovDev\MaxMessengerBot\Enums\UpdateType;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SecurityException;
|
||||
use BushlanovDev\MaxMessengerBot\Exceptions\SerializationException;
|
||||
use BushlanovDev\MaxMessengerBot\ModelFactory;
|
||||
@@ -14,20 +15,24 @@ use BushlanovDev\MaxMessengerBot\Models\MessageBody;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Recipient;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\AbstractUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\Models\Updates\MessageCreatedUpdate;
|
||||
use BushlanovDev\MaxMessengerBot\UpdateDispatcher;
|
||||
use BushlanovDev\MaxMessengerBot\WebhookHandler;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use LogicException;
|
||||
use phpmock\phpunit\PHPMock;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\UsesClass;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
#[CoversClass(WebhookHandler::class)]
|
||||
#[UsesClass(UpdateDispatcher::class)]
|
||||
#[UsesClass(Message::class)]
|
||||
#[UsesClass(MessageBody::class)]
|
||||
#[UsesClass(Recipient::class)]
|
||||
@@ -37,249 +42,147 @@ final class WebhookHandlerTest extends TestCase
|
||||
{
|
||||
use PHPMock;
|
||||
|
||||
private const string SECRET = 'my-secret-key';
|
||||
|
||||
private MockObject&Api $apiMock;
|
||||
private MockObject&ModelFactory $modelFactoryMock;
|
||||
private UpdateDispatcher $dispatcher;
|
||||
private MockObject&LoggerInterface $loggerMock;
|
||||
|
||||
private const string SECRET = 'my-super-secret-key';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->apiMock = $this->createMock(Api::class);
|
||||
$this->modelFactoryMock = $this->createMock(ModelFactory::class);
|
||||
$this->loggerMock = $this->createMock(LoggerInterface::class);
|
||||
$this->dispatcher = new UpdateDispatcher($this->apiMock);
|
||||
}
|
||||
|
||||
private function createValidUpdatePayload(): string
|
||||
private function createValidUpdate(): MessageCreatedUpdate
|
||||
{
|
||||
return json_encode([
|
||||
'update_type' => 'message_created',
|
||||
'timestamp' => 1678886400,
|
||||
'message' => [
|
||||
'timestamp' => 1678886400,
|
||||
'body' => ['mid' => 'm.123', 'seq' => 1, 'text' => 'Hello World'],
|
||||
'recipient' => ['chat_type' => 'dialog', 'chat_id' => 101, 'user_id' => 101],
|
||||
'sender' => null,
|
||||
'url' => null,
|
||||
],
|
||||
'user_locale' => 'ru-RU',
|
||||
]);
|
||||
$messageBody = new MessageBody('m.1', 1, 'Hi', null, null);
|
||||
$recipient = new Recipient(ChatType::Dialog, 1, null);
|
||||
$message = new Message(time(), $recipient, $messageBody, null, null, null, null);
|
||||
|
||||
return new MessageCreatedUpdate(time(), $message, 'ru-RU');
|
||||
}
|
||||
|
||||
private function createRealUpdateObject(array $data): MessageCreatedUpdate
|
||||
private function createMockRequest(string $body, string $signature): ServerRequestInterface
|
||||
{
|
||||
$messageBody = new MessageBody(
|
||||
$data['message']['body']['mid'],
|
||||
$data['message']['body']['seq'],
|
||||
$data['message']['body']['text'],
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$recipient = new Recipient(
|
||||
ChatType::from($data['message']['recipient']['chat_type']),
|
||||
$data['message']['recipient']['user_id'],
|
||||
$data['message']['recipient']['chat_id']
|
||||
);
|
||||
$message = new Message(
|
||||
$data['message']['timestamp'],
|
||||
$recipient,
|
||||
$messageBody,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
$streamMock = $this->createMock(StreamInterface::class);
|
||||
$streamMock->method('__toString')->willReturn($body);
|
||||
|
||||
return new MessageCreatedUpdate(
|
||||
$data['timestamp'],
|
||||
$message,
|
||||
$data['user_locale']
|
||||
);
|
||||
$requestMock = $this->createMock(ServerRequestInterface::class);
|
||||
$requestMock->method('getBody')->willReturn($streamMock);
|
||||
$requestMock->method('getHeaderLine')->with('X-Max-Bot-Api-Secret')->willReturn($signature);
|
||||
|
||||
return $requestMock;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleMethodProcessesPsr7RequestAndDispatches(): void
|
||||
#[DataProvider('successfulRequestProvider')]
|
||||
public function handleSuccessfulRequest(?string $secret, string $signatureHeader): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$signature = self::SECRET;
|
||||
$payload = '{"update_type":"message_created","timestamp":123}';
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
$expectedUpdate = $this->createValidUpdate();
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => $signature], $payload
|
||||
);
|
||||
$request = $this->createMockRequest($payload, $signatureHeader);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
$this->modelFactoryMock->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with($updateData)
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$handlerWasCalled = false;
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET);
|
||||
$webhookHandler->onMessageCreated(function (AbstractUpdate $update) use (&$handlerWasCalled, $expectedUpdate) {
|
||||
$this->assertSame($expectedUpdate, $update);
|
||||
$this->dispatcher->addHandler(UpdateType::MessageCreated, function () use (&$handlerWasCalled) {
|
||||
$handlerWasCalled = true;
|
||||
});
|
||||
|
||||
$webhookHandler->handle($request);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, $secret);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'The registered handler was not dispatched from handle() method.');
|
||||
$handler->handle($request);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'Dispatcher was not called on successful request.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dispatchCallsCorrectHandlerForRegisteredEvent(): void
|
||||
public static function successfulRequestProvider(): array
|
||||
{
|
||||
$handlerWasCalled = false;
|
||||
$updateData = json_decode($this->createValidUpdatePayload(), true);
|
||||
$testUpdate = $this->createRealUpdateObject($updateData);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
|
||||
$webhookHandler->onMessageCreated(
|
||||
function (MessageCreatedUpdate $update, Api $api) use (&$handlerWasCalled, $testUpdate) {
|
||||
$this->assertSame($testUpdate, $update);
|
||||
$this->assertSame($this->apiMock, $api);
|
||||
$handlerWasCalled = true;
|
||||
}
|
||||
);
|
||||
|
||||
$webhookHandler->dispatch($testUpdate);
|
||||
|
||||
$this->assertTrue($handlerWasCalled, 'The registered handler for onMessageCreated was not called.');
|
||||
return [
|
||||
'with correct secret' => [self::SECRET, self::SECRET],
|
||||
'with no secret configured' => [null, 'any-signature'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function dispatchDoesNothingForUnregisteredEvent(): void
|
||||
public function handleThrowsSecurityExceptionOnInvalidSignature(): void
|
||||
{
|
||||
$updateData = json_decode($this->createValidUpdatePayload(), true);
|
||||
$testUpdate = $this->createRealUpdateObject($updateData);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
|
||||
$webhookHandler->dispatch($testUpdate);
|
||||
|
||||
$this->expectNotToPerformAssertions();
|
||||
$this->expectException(SecurityException::class);
|
||||
$request = $this->createMockRequest('{}', 'wrong-signature');
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
$handler->handle($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForEmptyPayload(): void
|
||||
public function handleLogsWarningOnSignatureFailure(): void
|
||||
{
|
||||
$this->loggerMock->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Webhook signature verification failed', ['received_signature' => 'wrong-signature']);
|
||||
|
||||
$request = $this->createMockRequest('{}', 'wrong-signature');
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
|
||||
try {
|
||||
$handler->handle($request);
|
||||
} catch (SecurityException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleThrowsSerializationExceptionOnEmptyBody(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], '');
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
$request = $this->createMockRequest('', self::SECRET);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
$handler->handle($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForInvalidJson(): void
|
||||
public function handleThrowsSerializationExceptionOnInvalidJson(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Failed to decode webhook body as JSON.');
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], '{invalid-json');
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parseUpdateThrowsExceptionForInvalidSignature(): void
|
||||
{
|
||||
$this->expectException(SecurityException::class);
|
||||
$this->expectExceptionMessage('Signature verification failed.');
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'wrong-signature'], $this->createValidUpdatePayload()
|
||||
);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function signatureVerificationIsSkippedWhenNoSecretIsConfigured(): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'any-signature-or-empty'], $payload
|
||||
);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, null);
|
||||
|
||||
$result = $webhookHandler->parseUpdate($request);
|
||||
|
||||
$this->assertSame($expectedUpdate, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function getUpdateParsesRequestAndReturnsUpdateObject(): void
|
||||
{
|
||||
$payload = $this->createValidUpdatePayload();
|
||||
$updateData = json_decode($payload, true);
|
||||
$expectedUpdate = $this->createRealUpdateObject($updateData);
|
||||
|
||||
$request = new ServerRequest('POST', '/webhook', [], $payload);
|
||||
|
||||
$this->modelFactoryMock
|
||||
->expects($this->once())
|
||||
->method('createUpdate')
|
||||
->with($updateData)
|
||||
->willReturn($expectedUpdate);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$result = $webhookHandler->getUpdate($request);
|
||||
|
||||
$this->assertSame($expectedUpdate, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function handleWithoutRequestWhenGuzzleIsPresent(): void
|
||||
{
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->handle(null);
|
||||
$request = $this->createMockRequest('{invalid-json', self::SECRET);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, self::SECRET);
|
||||
$handler->handle($request);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[RunInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
public function handleWithoutRequestWhenGuzzleIsMissing(): void
|
||||
public function handleWithoutRequestThrowsLogicExceptionWhenGuzzleIsMissing(): void
|
||||
{
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessageMatches('/No ServerRequest was provided and "guzzlehttp\/psr7" is not found/');
|
||||
|
||||
$classExistsMock = $this->getFunctionMock('BushlanovDev\MaxMessengerBot', 'class_exists');
|
||||
|
||||
$classExistsMock->expects($this->once())->with('GuzzleHttp\Psr7\ServerRequest')->willReturn(false);
|
||||
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock);
|
||||
$webhookHandler->handle(null);
|
||||
$classExistsMock->expects($this->once())
|
||||
->with(\GuzzleHttp\Psr7\ServerRequest::class)
|
||||
->willReturn(false);
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, null);
|
||||
$handler->handle(null);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function verifySignatureLogsWarningOnFailure(): void
|
||||
public function handleWithoutRequestWhenGuzzleIsPresent(): void
|
||||
{
|
||||
$request = new ServerRequest(
|
||||
'POST', '/webhook', ['X-Max-Bot-Api-Secret' => 'wrong-signature'], $this->createValidUpdatePayload()
|
||||
);
|
||||
$webhookHandler = new WebhookHandler($this->apiMock, $this->modelFactoryMock, self::SECRET, $this->loggerMock);
|
||||
|
||||
$this->loggerMock
|
||||
->expects($this->once())
|
||||
->method('warning')
|
||||
->with('Webhook signature verification failed', ['received_signature' => 'wrong-signature']);
|
||||
|
||||
$this->expectException(SecurityException::class);
|
||||
$webhookHandler->parseUpdate($request);
|
||||
if (!class_exists(\GuzzleHttp\Psr7\ServerRequest::class)) {
|
||||
$this->markTestSkipped('guzzlehttp/psr7 is not installed, cannot run this test.');
|
||||
}
|
||||
$this->expectException(SerializationException::class);
|
||||
$this->expectExceptionMessage('Webhook body is empty.');
|
||||
$handler = new WebhookHandler($this->dispatcher, $this->modelFactoryMock, $this->loggerMock, null);
|
||||
$handler->handle(null);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user