|
4 | 4 |
|
5 | 5 | use KLP\KlpMcpServer\Transports\SseAdapters\SseAdapterInterface; |
6 | 6 | use KLP\KlpMcpServer\Transports\SseTransport; |
| 7 | +use KLP\KlpMcpServer\Transports\SseTransportException; |
| 8 | +use PHPUnit\Framework\Attributes\CoversClass; |
7 | 9 | use PHPUnit\Framework\Attributes\Small; |
8 | 10 | use PHPUnit\Framework\MockObject\MockObject; |
9 | 11 | use PHPUnit\Framework\TestCase; |
@@ -74,7 +76,7 @@ public function test_initialize_generates_client_id_and_sends_endpoint(): void |
74 | 76 | /** |
75 | 77 | * Test that initialize does not overwrite an existing client ID. |
76 | 78 | */ |
77 | | - public function test_initialize_d_does_not_overwrite_existing_client_id(): void |
| 79 | + public function test_initialize_does_not_overwrite_existing_client_id(): void |
78 | 80 | { |
79 | 81 | // Arrange |
80 | 82 | $existingClientId = 'predefined-client-id'; |
@@ -125,21 +127,6 @@ public function test_send_array_message(): void |
125 | 127 | ); |
126 | 128 | } |
127 | 129 |
|
128 | | - private function getProtectedProperty(SseTransport $instance, string $propertyName) |
129 | | - { |
130 | | - $reflection = new \ReflectionClass($instance); |
131 | | - $prop = $reflection->getProperty($propertyName); |
132 | | - |
133 | | - return $prop->getValue($instance); |
134 | | - } |
135 | | - |
136 | | - private function setProtectedProperty(SseTransport $instance, string $propertyName, string $propertyValue): void |
137 | | - { |
138 | | - $reflection = new \ReflectionClass($instance); |
139 | | - $prop = $reflection->getProperty($propertyName); |
140 | | - $prop->setValue($instance, $propertyValue); |
141 | | - } |
142 | | - |
143 | 130 | /** |
144 | 131 | * Test that the start() method sets the connected flag to true and initializes the transport. |
145 | 132 | */ |
@@ -308,4 +295,306 @@ public function test_close_logs_exceptions_in_handlers_or_cleanup(): void |
308 | 295 | ob_end_clean(); |
309 | 296 | } |
310 | 297 | } |
| 298 | + |
| 299 | + /** |
| 300 | + * Test that `receive` returns an empty array when no adapter is configured. |
| 301 | + */ |
| 302 | + public function test_receive_returns_empty_array_without_adapter(): void |
| 303 | + { |
| 304 | + // Act |
| 305 | + $result = $this->instance->receive(); |
| 306 | + |
| 307 | + // Assert |
| 308 | + $this->assertEquals([], $result, 'Expected receive to return an empty array when no adapter is set.'); |
| 309 | + } |
| 310 | + |
| 311 | + /** |
| 312 | + * Test that `receive` logs an info message when no adapter is configured. |
| 313 | + */ |
| 314 | + public function test_receive_logs_info_when_no_adapter(): void |
| 315 | + { |
| 316 | + // Arrange |
| 317 | + $this->loggerMock->expects($this->once()) |
| 318 | + ->method('info') |
| 319 | + ->with('SSE Transport::receive called but no adapter is configured.'); |
| 320 | + |
| 321 | + // Act |
| 322 | + $this->instance->receive(); |
| 323 | + } |
| 324 | + |
| 325 | + /** |
| 326 | + * Test that `receive` returns received messages when an adapter and client ID are set. |
| 327 | + */ |
| 328 | + public function test_receive_returns_adapter_messages(): void |
| 329 | + { |
| 330 | + // Arrange |
| 331 | + $this->setProtectedProperty($this->instance, 'connected', true); |
| 332 | + $this->setProtectedProperty($this->instance, 'clientId', 'test-client-id'); |
| 333 | + $adapterMock = $this->createMock(SseAdapterInterface::class); |
| 334 | + $this->instance->setAdapter($adapterMock); |
| 335 | + |
| 336 | + $messages = [['type' => 'test', 'data' => 'value']]; |
| 337 | + $adapterMock->expects($this->once()) |
| 338 | + ->method('receiveMessages') |
| 339 | + ->with('test-client-id') |
| 340 | + ->willReturn($messages); |
| 341 | + |
| 342 | + // Act |
| 343 | + $result = $this->instance->receive(); |
| 344 | + |
| 345 | + // Assert |
| 346 | + $this->assertEquals($messages, $result, 'Expected received messages to match the adapter output.'); |
| 347 | + } |
| 348 | + |
| 349 | + /** |
| 350 | + * Test that `receive` calls the adapter's `receiveMessages` method with the correct client ID. |
| 351 | + */ |
| 352 | + public function test_receive_calls_adapter_receiveMessages_with_correct_client_id(): void |
| 353 | + { |
| 354 | + // Arrange |
| 355 | + $this->setProtectedProperty($this->instance, 'connected', true); |
| 356 | + $this->setProtectedProperty($this->instance, 'clientId', 'test-client-id'); |
| 357 | + $adapterMock = $this->createMock(SseAdapterInterface::class); |
| 358 | + $this->instance->setAdapter($adapterMock); |
| 359 | + |
| 360 | + $adapterMock->expects($this->once()) |
| 361 | + ->method('receiveMessages') |
| 362 | + ->with('test-client-id'); |
| 363 | + |
| 364 | + // Act |
| 365 | + $this->instance->receive(); |
| 366 | + } |
| 367 | + |
| 368 | + /** |
| 369 | + * Test that `receive` triggers error handlers if the adapter throws an exception. |
| 370 | + */ |
| 371 | + public function test_receive_triggers_error_handlers_on_adapter_exception(): void |
| 372 | + { |
| 373 | + // Arrange |
| 374 | + $this->setProtectedProperty($this->instance, 'connected', true); |
| 375 | + $this->setProtectedProperty($this->instance, 'clientId', 'test-client-id'); |
| 376 | + $adapterMock = $this->createMock(SseAdapterInterface::class); |
| 377 | + $this->instance->setAdapter($adapterMock); |
| 378 | + |
| 379 | + $adapterMock->expects($this->once()) |
| 380 | + ->method('receiveMessages') |
| 381 | + ->willThrowException(new \Exception('Adapter Exception')); |
| 382 | + |
| 383 | + $errorTriggered = false; |
| 384 | + $this->instance->onError(function (string $error) use (&$errorTriggered) { |
| 385 | + $errorTriggered = true; |
| 386 | + $this->assertStringContainsString('Adapter Exception', $error); |
| 387 | + }); |
| 388 | + |
| 389 | + // Act |
| 390 | + $this->instance->receive(); |
| 391 | + |
| 392 | + // Assert |
| 393 | + $this->assertTrue($errorTriggered, 'Error handlers were not triggered on adapter exception.'); |
| 394 | + } |
| 395 | + |
| 396 | + /** |
| 397 | + * Test that `pushMessage` calls the adapter's `pushMessage` method with correct parameters. |
| 398 | + */ |
| 399 | + public function test_push_message_calls_adapter_correctly(): void |
| 400 | + { |
| 401 | + // Arrange |
| 402 | + $clientId = 'test-client-id'; |
| 403 | + $message = ['key' => 'value']; |
| 404 | + $expectedMessage = json_encode($message); |
| 405 | + |
| 406 | + $adapterMock = $this->createMock(SseAdapterInterface::class); |
| 407 | + $adapterMock->expects($this->once()) |
| 408 | + ->method('pushMessage') |
| 409 | + ->with($clientId, $expectedMessage); |
| 410 | + |
| 411 | + $this->instance->setAdapter($adapterMock); |
| 412 | + |
| 413 | + // Act |
| 414 | + $this->instance->pushMessage($clientId, $message); |
| 415 | + } |
| 416 | + |
| 417 | + /** |
| 418 | + * Test that `pushMessage` throws an exception if adapter is not set. |
| 419 | + */ |
| 420 | + public function test_push_message_throws_exception_without_adapter(): void |
| 421 | + { |
| 422 | + // Expect exception |
| 423 | + $this->expectException(SseTransportException::class); |
| 424 | + $this->expectExceptionMessage('Cannot push message: SSE Adapter is not configured.'); |
| 425 | + |
| 426 | + // Act |
| 427 | + $this->instance->pushMessage('client-id', ['key' => 'value']); |
| 428 | + } |
| 429 | + |
| 430 | + /** |
| 431 | + * Test that `pushMessage` throws an exception if JSON encoding fails. |
| 432 | + */ |
| 433 | + public function test_push_message_throws_exception_on_json_error(): void |
| 434 | + { |
| 435 | + // Arrange |
| 436 | + $clientId = 'test-client-id'; |
| 437 | + $invalidMessage = "\xB1\x31"; // Invalid UTF-8 sequence |
| 438 | + |
| 439 | + $adapterMock = $this->createMock(SseAdapterInterface::class); |
| 440 | + $this->instance->setAdapter($adapterMock); |
| 441 | + |
| 442 | + // Expect exception |
| 443 | + $this->expectException(SseTransportException::class); |
| 444 | + $this->expectExceptionMessage('Failed to JSON encode message for pushing'); |
| 445 | + |
| 446 | + // Act |
| 447 | + $this->instance->pushMessage($clientId, [$invalidMessage]); |
| 448 | + } |
| 449 | + |
| 450 | + /** |
| 451 | + * Test that `processMessage` calls all registered message handlers with the correct parameters. |
| 452 | + */ |
| 453 | + public function test_process_message_invokes_handlers(): void |
| 454 | + { |
| 455 | + // Arrange |
| 456 | + $messageHandlers = []; |
| 457 | + $this->instance->onMessage(function (string $clientId, array $message) use (&$messageHandlers) { |
| 458 | + $messageHandlers[] = ['clientId' => $clientId, 'message' => $message]; |
| 459 | + }); |
| 460 | + |
| 461 | + $clientId = 'test-client-id'; |
| 462 | + $message = ['key' => 'value']; |
| 463 | + |
| 464 | + // Act |
| 465 | + $this->instance->processMessage($clientId, $message); |
| 466 | + |
| 467 | + // Assert |
| 468 | + $this->assertCount(1, $messageHandlers); |
| 469 | + $this->assertSame($clientId, $messageHandlers[0]['clientId']); |
| 470 | + $this->assertSame($message, $messageHandlers[0]['message']); |
| 471 | + } |
| 472 | + |
| 473 | + /** |
| 474 | + * Test that `triggerError` invokes all registered error handlers with the correct message. |
| 475 | + */ |
| 476 | + public function test_trigger_error_invokes_registered_handlers(): void |
| 477 | + { |
| 478 | + // Arrange |
| 479 | + $errorTriggered = []; |
| 480 | + $this->instance->onError(function (string $error) use (&$errorTriggered) { |
| 481 | + $errorTriggered[] = $error; |
| 482 | + }); |
| 483 | + $this->instance->onError(function (string $error) use (&$errorTriggered) { |
| 484 | + $errorTriggered[] = "Handler 2: $error"; |
| 485 | + }); |
| 486 | + |
| 487 | + $message = 'Test Error Message'; |
| 488 | + |
| 489 | + // Act |
| 490 | + $this->invokeProtectedMethod($this->instance, 'triggerError', [$message]); |
| 491 | + |
| 492 | + // Assert |
| 493 | + $this->assertCount(2, $errorTriggered); |
| 494 | + $this->assertSame($message, $errorTriggered[0]); |
| 495 | + $this->assertSame("Handler 2: $message", $errorTriggered[1]); |
| 496 | + } |
| 497 | + |
| 498 | + /** |
| 499 | + * Test that `triggerError` logs exceptions thrown by error handlers. |
| 500 | + */ |
| 501 | + public function test_trigger_error_logs_exceptions_in_handlers(): void |
| 502 | + { |
| 503 | + // Arrange |
| 504 | + $message = 'Error Message'; |
| 505 | + $invocations = [ |
| 506 | + 'SSE Transport error: Error Message', |
| 507 | + 'Error in SSE error handler itself: Test Exception', |
| 508 | + ]; |
| 509 | + $this->loggerMock->expects($matcher = $this->exactly(2)) |
| 510 | + ->method('error') |
| 511 | + ->with($this->callback(function ($arg) use (&$invocations, $matcher) { |
| 512 | + $this->assertEquals($arg, $invocations[$matcher->numberOfInvocations() - 1]); |
| 513 | + |
| 514 | + return true; |
| 515 | + })); |
| 516 | + |
| 517 | + $this->instance->onError(function () { |
| 518 | + throw new \Exception('Test Exception'); |
| 519 | + }); |
| 520 | + |
| 521 | + $handlerCalled = false; |
| 522 | + $this->instance->onError(function () use (&$handlerCalled) { |
| 523 | + $handlerCalled = true; |
| 524 | + }); |
| 525 | + |
| 526 | + |
| 527 | + // Act |
| 528 | + $this->invokeProtectedMethod($this->instance, 'triggerError', [$message]); |
| 529 | + |
| 530 | + // Assert |
| 531 | + $this->assertTrue($handlerCalled, 'The second handler was not called after the exception.'); |
| 532 | + } |
| 533 | + |
| 534 | + /** |
| 535 | + * Test that `processMessage` logs exceptions in message handlers and continues execution. |
| 536 | + */ |
| 537 | + public function test_process_message_logs_exceptions_in_handlers(): void |
| 538 | + { |
| 539 | + // Arrange |
| 540 | + $this->loggerMock->expects($this->once()) |
| 541 | + ->method('error') |
| 542 | + ->with($this->stringContains('Error processing SSE message via handler: Test Exception')); |
| 543 | + |
| 544 | + $this->instance->onMessage(function () { |
| 545 | + throw new \Exception('Test Exception'); |
| 546 | + }); |
| 547 | + |
| 548 | + $handlerCalled = false; |
| 549 | + $this->instance->onMessage(function () use (&$handlerCalled) { |
| 550 | + $handlerCalled = true; |
| 551 | + }); |
| 552 | + |
| 553 | + $clientId = 'test-client-id'; |
| 554 | + $message = ['key' => 'value']; |
| 555 | + |
| 556 | + // Act |
| 557 | + $this->instance->processMessage($clientId, $message); |
| 558 | + |
| 559 | + // Assert |
| 560 | + $this->assertTrue($handlerCalled, 'The second handler was not called after the exception.'); |
| 561 | + } |
| 562 | + |
| 563 | + /** |
| 564 | + * Test that `processMessage` does nothing when no handlers are registered. |
| 565 | + */ |
| 566 | + public function test_process_message_does_nothing_without_handlers(): void |
| 567 | + { |
| 568 | + // Arrange |
| 569 | + $clientId = 'test-client-id'; |
| 570 | + $message = ['key' => 'value']; |
| 571 | + |
| 572 | + // Assert no exception is thrown or output generated |
| 573 | + $this->expectNotToPerformAssertions(); |
| 574 | + |
| 575 | + // Act |
| 576 | + $this->instance->processMessage($clientId, $message); |
| 577 | + } |
| 578 | + |
| 579 | + private function invokeProtectedMethod(SseTransport $instance, string $string, array $array) |
| 580 | + { |
| 581 | + $reflection = new \ReflectionClass($instance); |
| 582 | + $method = $reflection->getMethod($string); |
| 583 | + $method->invokeArgs($instance, $array); |
| 584 | + } |
| 585 | + |
| 586 | + private function getProtectedProperty(SseTransport $instance, string $propertyName) |
| 587 | + { |
| 588 | + $reflection = new \ReflectionClass($instance); |
| 589 | + $prop = $reflection->getProperty($propertyName); |
| 590 | + |
| 591 | + return $prop->getValue($instance); |
| 592 | + } |
| 593 | + |
| 594 | + private function setProtectedProperty(SseTransport $instance, string $propertyName, string $propertyValue): void |
| 595 | + { |
| 596 | + $reflection = new \ReflectionClass($instance); |
| 597 | + $prop = $reflection->getProperty($propertyName); |
| 598 | + $prop->setValue($instance, $propertyValue); |
| 599 | + } |
311 | 600 | } |
0 commit comments