From 20f43fb4ac0c610185db562dc0e625aa4b6f9039 Mon Sep 17 00:00:00 2001 From: bigdevlarry Date: Sun, 1 Feb 2026 18:25:43 +0000 Subject: [PATCH] Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe Add missing handler for resource subscribe and unsubscribe --- CHANGELOG.md | 4 + src/Capability/Registry.php | 2 +- src/Server/Builder.php | 24 ++- .../Request/ResourceSubscribeHandler.php | 72 +++++++ .../Request/ResourceUnsubscribeHandler.php | 72 +++++++ .../Resource/SessionSubscriptionManager.php | 94 ++++++++++ .../Resource/SubscriptionManagerInterface.php | 53 ++++++ tests/Conformance/conformance-baseline.yml | 2 - tests/Conformance/server.php | 3 +- .../Handler/Request/ResourceSubscribeTest.php | 142 ++++++++++++++ .../Request/ResourceUnsubscribeTest.php | 149 +++++++++++++++ .../Server/SessionSubscriptionManagerTest.php | 176 ++++++++++++++++++ 12 files changed, 784 insertions(+), 9 deletions(-) create mode 100644 src/Server/Handler/Request/ResourceSubscribeHandler.php create mode 100644 src/Server/Handler/Request/ResourceUnsubscribeHandler.php create mode 100644 src/Server/Resource/SessionSubscriptionManager.php create mode 100644 src/Server/Resource/SubscriptionManagerInterface.php create mode 100644 tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php create mode 100644 tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php create mode 100644 tests/Unit/Server/SessionSubscriptionManagerTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b6667b03..8f2a09cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to `mcp/sdk` will be documented in this file. +0.4.0 +----- +* Add missing handlers for resource subscribe/unsubscribe and persist subscriptions via session + 0.3.0 ----- diff --git a/src/Capability/Registry.php b/src/Capability/Registry.php index 08348e8a..2a327ae4 100644 --- a/src/Capability/Registry.php +++ b/src/Capability/Registry.php @@ -352,7 +352,7 @@ public function getDiscoveryState(): DiscoveryState } /** - * Set discovery state, replacing all discovered elements. + * Set the discovery state, replacing all discovered elements. * Manual elements are preserved. */ public function setDiscoveryState(DiscoveryState $state): void diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 9e9b6b2f..c39c2c2f 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -33,6 +33,8 @@ use Mcp\Server; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; +use Mcp\Server\Resource\SessionSubscriptionManager; +use Mcp\Server\Resource\SubscriptionManagerInterface; use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\SessionFactory; use Mcp\Server\Session\SessionFactoryInterface; @@ -54,6 +56,8 @@ final class Builder private RegistryInterface $registry; + private ?SubscriptionManagerInterface $subscriptionManager = null; + private ?LoggerInterface $logger = null; private ?CacheInterface $discoveryCache = null; @@ -309,6 +313,13 @@ public function setDiscoverer(DiscovererInterface $discoverer): self return $this; } + public function setResourceSubscriptionManager(SubscriptionManagerInterface $subscriptionManager): self + { + $this->subscriptionManager = $subscriptionManager; + + return $this; + } + public function setSession( SessionStoreInterface $sessionStore, SessionFactoryInterface $sessionFactory = new SessionFactory(), @@ -489,12 +500,16 @@ public function build(): Server $logger = $this->logger ?? new NullLogger(); $container = $this->container ?? new Container(); $registry = $this->registry ?? new Registry($this->eventDispatcher, $logger); - + $subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger); $loaders = [ ...$this->loaders, new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator), ]; + $sessionTtl = $this->sessionTtl ?? 3600; + $sessionFactory = $this->sessionFactory ?? new SessionFactory(); + $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + if (null !== $this->discoveryBasePath) { $discoverer = $this->discoverer ?? $this->createDiscoverer($logger); $loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer); @@ -504,16 +519,13 @@ public function build(): Server $loader->load($registry); } - $sessionTtl = $this->sessionTtl ?? 3600; - $sessionFactory = $this->sessionFactory ?? new SessionFactory(); - $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); $messageFactory = MessageFactory::make(); $capabilities = $this->serverCapabilities ?? new ServerCapabilities( tools: $registry->hasTools(), toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, resources: $registry->hasResources() || $registry->hasResourceTemplates(), - resourcesSubscribe: false, + resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(), resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, prompts: $registry->hasPrompts(), promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface, @@ -536,6 +548,8 @@ public function build(): Server new Handler\Request\ListToolsHandler($registry, $this->paginationLimit), new Handler\Request\PingHandler(), new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger), + new Handler\Request\ResourceSubscribeHandler($registry, $subscriptionManager, $logger), + new Handler\Request\ResourceUnsubscribeHandler($registry, $subscriptionManager, $logger), new Handler\Request\SetLogLevelHandler(), ]); diff --git a/src/Server/Handler/Request/ResourceSubscribeHandler.php b/src/Server/Handler/Request/ResourceSubscribeHandler.php new file mode 100644 index 00000000..ba0c8793 --- /dev/null +++ b/src/Server/Handler/Request/ResourceSubscribeHandler.php @@ -0,0 +1,72 @@ + + * + * @author Larry Sule-balogun + */ +final class ResourceSubscribeHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly SubscriptionManagerInterface $subscriptionManager, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ResourceSubscribeRequest; + } + + /** + * @throws InvalidArgumentException + */ + public function handle(Request $request, SessionInterface $session): Response|Error + { + \assert($request instanceof ResourceSubscribeRequest); + + $uri = $request->uri; + + try { + $this->registry->getResource($uri); + } catch (ResourceNotFoundException $e) { + $this->logger->error('Resource not found', ['uri' => $uri]); + + return Error::forResourceNotFound($e->getMessage(), $request->getId()); + } + + $this->logger->debug('Subscribing to resource', ['uri' => $uri]); + + $this->subscriptionManager->subscribe($session, $uri); + + return new Response( + $request->getId(), + new EmptyResult(), + ); + } +} diff --git a/src/Server/Handler/Request/ResourceUnsubscribeHandler.php b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php new file mode 100644 index 00000000..26db50e7 --- /dev/null +++ b/src/Server/Handler/Request/ResourceUnsubscribeHandler.php @@ -0,0 +1,72 @@ + + * + * @author Larry Sule-balogun + */ +final class ResourceUnsubscribeHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly RegistryInterface $registry, + private readonly SubscriptionManagerInterface $subscriptionManager, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ResourceUnsubscribeRequest; + } + + /** + * @throws InvalidArgumentException + */ + public function handle(Request $request, SessionInterface $session): Response|Error + { + \assert($request instanceof ResourceUnsubscribeRequest); + + $uri = $request->uri; + + try { + $this->registry->getResource($uri); + } catch (ResourceNotFoundException $e) { + $this->logger->error('Resource not found', ['uri' => $uri]); + + return Error::forResourceNotFound($e->getMessage(), $request->getId()); + } + + $this->logger->debug('Unsubscribing from resource', ['uri' => $uri]); + + $this->subscriptionManager->unsubscribe($session, $uri); + + return new Response( + $request->getId(), + new EmptyResult(), + ); + } +} diff --git a/src/Server/Resource/SessionSubscriptionManager.php b/src/Server/Resource/SessionSubscriptionManager.php new file mode 100644 index 00000000..f3ea4d5b --- /dev/null +++ b/src/Server/Resource/SessionSubscriptionManager.php @@ -0,0 +1,94 @@ + + */ +final class SessionSubscriptionManager implements SubscriptionManagerInterface +{ + public function __construct( + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * @throws InvalidArgumentException + */ + public function subscribe(SessionInterface $session, string $uri): void + { + $subscriptions = $session->get('resource_subscriptions', []); + $subscriptions[$uri] = true; + $session->set('resource_subscriptions', $subscriptions); + $session->save(); + } + + /** + * @throws InvalidArgumentException + */ + public function unsubscribe(SessionInterface $session, string $uri): void + { + $subscriptions = $session->get('resource_subscriptions', []); + unset($subscriptions[$uri]); + $session->set('resource_subscriptions', $subscriptions); + $session->save(); + } + + /** + * @throws InvalidArgumentException + */ + public function isSubscribed(SessionInterface $session, string $uri): bool + { + $subscriptions = $session->get('resource_subscriptions', []); + + return isset($subscriptions[$uri]); + } + + /** + * @throws InvalidArgumentException + */ + public function notifyResourceChanged(Protocol $protocol, SessionInterface $session, string $uri): void + { + $activeSession = $this->isSubscribed($session, $uri); + if (!$activeSession) { + return; + } + + try { + $protocol->sendNotification( + new ResourceUpdatedNotification($uri), + $session + ); + } catch (InvalidArgumentException $e) { + $this->logger->error('Error sending resource notification to session', [ + 'session_id' => $session->getId()->toRfc4122(), + 'uri' => $uri, + 'exception' => $e, + ]); + + throw $e; + } + } +} diff --git a/src/Server/Resource/SubscriptionManagerInterface.php b/src/Server/Resource/SubscriptionManagerInterface.php new file mode 100644 index 00000000..b31f3d73 --- /dev/null +++ b/src/Server/Resource/SubscriptionManagerInterface.php @@ -0,0 +1,53 @@ + + */ +interface SubscriptionManagerInterface +{ + /** + * Subscribes a session to a specific resource URI. + * + * @throws InvalidArgumentException + */ + public function subscribe(SessionInterface $session, string $uri): void; + + /** + * Unsubscribes a session from a specific resource URI. + * + * @throws InvalidArgumentException + */ + public function unsubscribe(SessionInterface $session, string $uri): void; + + /** + * Check if a session is subscribed to a resource URI. + * + * @throws InvalidArgumentException + */ + public function isSubscribed(SessionInterface $session, string $uri): bool; + + /** + * Notifies all sessions subscribed to the given resource URI that the + * resource has changed. Sends a ResourceUpdatedNotification for each subscriber. + * + * @throws InvalidArgumentException + */ + public function notifyResourceChanged(Protocol $protocol, SessionInterface $session, string $uri): void; +} diff --git a/tests/Conformance/conformance-baseline.yml b/tests/Conformance/conformance-baseline.yml index e1251a60..2613c0d4 100644 --- a/tests/Conformance/conformance-baseline.yml +++ b/tests/Conformance/conformance-baseline.yml @@ -2,6 +2,4 @@ server: - tools-call-elicitation - elicitation-sep1034-defaults - elicitation-sep1330-enums - - resources-subscribe - - resources-unsubscribe - dns-rebinding-protection diff --git a/tests/Conformance/server.php b/tests/Conformance/server.php index bbcbaa10..1b69b8f0 100644 --- a/tests/Conformance/server.php +++ b/tests/Conformance/server.php @@ -9,6 +9,8 @@ * file that was distributed with this source code. */ +ini_set('display_errors', '0'); + require_once dirname(__DIR__, 2).'/vendor/autoload.php'; use Http\Discovery\Psr17Factory; @@ -51,7 +53,6 @@ ->addResource(static fn () => 'This is the content of the static text resource.', 'test://static-text', 'static-text', 'A static text resource for testing') ->addResource(static fn () => fopen('data://image/png;base64,'.Elements::TEST_IMAGE_BASE64, 'r'), 'test://static-binary', 'static-binary', 'A static binary resource (image) for testing') ->addResourceTemplate([Elements::class, 'resourceTemplate'], 'test://template/{id}/data', 'template', 'A resource template with parameter substitution', 'application/json') - // TODO: Handler for resources/subscribe and resources/unsubscribe ->addResource(static fn () => 'Watched resource content', 'test://watched-resource', 'watched-resource', 'A resource that can be watched') // Prompts ->addPrompt(static fn () => [['role' => 'user', 'content' => 'This is a simple prompt for testing.']], 'test_simple_prompt', 'A simple prompt without arguments') diff --git a/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php new file mode 100644 index 00000000..00512fa4 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceSubscribeTest.php @@ -0,0 +1,142 @@ +registry = $this->createMock(RegistryInterface::class); + $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + $this->handler = new ResourceSubscribeHandler($this->registry, $this->subscriptionManager); + } + + #[TestDox('Client can successfully subscribe to a resource')] + public function testClientCanSuccessfulSubscribeToAResource(): void + { + $uri = 'file://documents/readme.txt'; + $request = $this->createResourceSubscribeRequest($uri); + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->subscriptionManager->expects($this->once()) + ->method('subscribe') + ->with($this->session, $uri); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + #[TestDox('Gracefully handle duplicate subscription to a resource')] + public function testDuplicateSubscriptionIsGracefullyHandled(): void + { + $uri = 'file://documents/readme.txt'; + $request = $this->createResourceSubscribeRequest($uri); + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->exactly(2)) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->subscriptionManager + ->expects($this->exactly(2)) + ->method('subscribe') + ->with($this->session, $uri); + + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Response::class, $response1); + $this->assertInstanceOf(Response::class, $response2); + $this->assertEquals($request->getId(), $response1->id); + $this->assertEquals($request->getId(), $response2->id); + $this->assertInstanceOf(EmptyResult::class, $response1->result); + $this->assertInstanceOf(EmptyResult::class, $response2->result); + } + + #[TestDox('Subscription to a resource with an empty uri throws InvalidArgumentException')] + public function testSubscribeWithEmptyUriThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "uri" parameter for resources/subscribe.'); + + $this->createResourceSubscribeRequest(''); + } + + #[TestDox('Subscription to a resource with an invalid uri throws ResourceNotException')] + public function testHandleSubscribeResourceNotFoundException(): void + { + $uri = 'file://missing/file.txt'; + $request = $this->createResourceSubscribeRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals(\sprintf('Resource not found for uri: "%s".', $uri), $response->message); + } + + private function createResourceSubscribeRequest(string $uri): ResourceSubscribeRequest + { + return ResourceSubscribeRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ResourceSubscribeRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} diff --git a/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php new file mode 100644 index 00000000..509c256d --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ResourceUnsubscribeTest.php @@ -0,0 +1,149 @@ +registry = $this->createMock(RegistryInterface::class); + $this->subscriptionManager = $this->createMock(SubscriptionManagerInterface::class); + $this->session = $this->createMock(SessionInterface::class); + + $this->handler = new ResourceUnsubscribeHandler($this->registry, $this->subscriptionManager); + } + + #[TestDox('Client can unsubscribe from a resource')] + public function testClientCanUnsubscribeFromAResource(): void + { + // Arrange + $uri = 'file://documents/readme.txt'; + $request = $this->createResourceUnsubscribeRequest($uri); + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->subscriptionManager->expects($this->once()) + ->method('unsubscribe') + ->with($this->session, $uri); + + // Act + $response = $this->handler->handle($request, $this->session); + + // Assert + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals($request->getId(), $response->id); + $this->assertInstanceOf(EmptyResult::class, $response->result); + } + + #[TestDox('Gracefully handle duplicate unsubscription from a resource')] + public function testDuplicateUnSubscriptionIsGracefullyHandled(): void + { + // Arrange + $uri = 'file://documents/readme.txt'; + $request = $this->createResourceUnsubscribeRequest($uri); + $resourceReference = $this->getMockBuilder(ResourceReference::class) + ->setConstructorArgs([new Resource($uri, 'test', mimeType: 'text/plain'), []]) + ->getMock(); + + $this->registry + ->expects($this->exactly(2)) + ->method('getResource') + ->with($uri) + ->willReturn($resourceReference); + + $this->subscriptionManager + ->expects($this->exactly(2)) + ->method('unsubscribe') + ->with($this->session, $uri); + + // Act + $response1 = $this->handler->handle($request, $this->session); + $response2 = $this->handler->handle($request, $this->session); + + // Assert + $this->assertInstanceOf(Response::class, $response1); + $this->assertInstanceOf(Response::class, $response2); + $this->assertEquals($request->getId(), $response1->id); + $this->assertEquals($request->getId(), $response2->id); + $this->assertInstanceOf(EmptyResult::class, $response1->result); + $this->assertInstanceOf(EmptyResult::class, $response2->result); + } + + #[TestDox('Unsubscription from a resource with an invalid uri throws ResourceNotException')] + public function testHandleUnsubscribeResourceNotFoundException(): void + { + $uri = 'file://missing/file.txt'; + $request = $this->createResourceUnsubscribeRequest($uri); + $exception = new ResourceNotFoundException($uri); + + $this->registry + ->expects($this->once()) + ->method('getResource') + ->with($uri) + ->willThrowException($exception); + + $response = $this->handler->handle($request, $this->session); + + $this->assertInstanceOf(Error::class, $response); + $this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code); + $this->assertEquals(\sprintf('Resource not found for uri: "%s".', $uri), $response->message); + } + + #[TestDox('Unsubscription from a resource with an empty uri throws InvalidArgumentException')] + public function testUnsubscribeWithEmptyUriThrowsError(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Missing or invalid "uri" parameter for resources/unsubscribe.'); + + $this->createResourceUnsubscribeRequest(''); + } + + private function createResourceUnsubscribeRequest(string $uri): ResourceUnsubscribeRequest + { + return ResourceUnsubscribeRequest::fromArray([ + 'jsonrpc' => '2.0', + 'method' => ResourceUnsubscribeRequest::getMethod(), + 'id' => 'test-request-'.uniqid(), + 'params' => [ + 'uri' => $uri, + ], + ]); + } +} diff --git a/tests/Unit/Server/SessionSubscriptionManagerTest.php b/tests/Unit/Server/SessionSubscriptionManagerTest.php new file mode 100644 index 00000000..e0fd2847 --- /dev/null +++ b/tests/Unit/Server/SessionSubscriptionManagerTest.php @@ -0,0 +1,176 @@ +logger = $this->createMock(LoggerInterface::class); + $this->protocol = $this->createMock(Protocol::class); + $this->subscriptionManager = new SessionSubscriptionManager($this->logger); + } + + #[TestDox('Subscribing to a resource sends update notifications')] + public function testSubscribeAndSendsNotification(): void + { + // Arrange + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $uri = 'test://resource'; + + $session->method('get') + ->with('resource_subscriptions', []) + ->willReturnOnConsecutiveCalls( + [], + [$uri => true] + ); + + $session->expects($this->once())->method('set')->with('resource_subscriptions', [$uri => true]); + $session->expects($this->once())->method('save'); + + // Act + $this->subscriptionManager->subscribe($session, $uri); + + // Assert + $this->protocol->expects($this->once()) + ->method('sendNotification') + ->with($this->isInstanceOf(ResourceUpdatedNotification::class)); + + $this->subscriptionManager->notifyResourceChanged($this->protocol, $session, $uri); + } + + #[TestDox('Unsubscribe from a resource')] + public function testUnsubscribeFromAResource(): void + { + // Arrange + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $uri = 'test://resource'; + + $session->method('get') + ->with('resource_subscriptions', []) + ->willReturnOnConsecutiveCalls( + [], + [$uri => true], + [$uri => true], + ); + + $session->expects($this->exactly(2))->method('set'); + $session->expects($this->exactly(2))->method('save'); + + // Act + $this->subscriptionManager->subscribe($session, $uri); + + $this->protocol->expects($this->once())->method('sendNotification'); + $this->subscriptionManager->notifyResourceChanged($this->protocol, $session, $uri); + + $this->subscriptionManager->unsubscribe($session, $uri); + } + + #[TestDox('Unsubscribing from a resource verifies that no notification is sent')] + public function testUnsubscribeDoesNotSendNotifications(): void + { + // Arrange + $protocol = $this->createMock(Protocol::class); + $session = $this->createMock(SessionInterface::class); + $session->method('getId')->willReturn(Uuid::v4()); + $uri = 'test://resource'; + + $session->method('get') + ->with('resource_subscriptions', []) + ->willReturnOnConsecutiveCalls( + [], + [$uri => true], + [] + ); + + $session->expects($this->exactly(2))->method('set'); + $session->expects($this->exactly(2))->method('save'); + + // Act + $this->subscriptionManager->subscribe($session, $uri); + $this->subscriptionManager->unsubscribe($session, $uri); + + // Assert + $protocol->expects($this->never())->method('sendNotification'); + $this->subscriptionManager->notifyResourceChanged($protocol, $session, $uri); + } + + #[TestDox('Logs error when notification fails to send')] + public function testLogsErrorWhenNotificationFails(): void + { + // Arrange + $protocol = $this->createMock(Protocol::class); + $session = $this->createMock(SessionInterface::class); + $uuid = Uuid::v4(); + $session->method('getId')->willReturn($uuid); + $uri = 'test://resource'; + + $session->method('get') + ->with('resource_subscriptions', []) + ->willReturnOnConsecutiveCalls( + [], + [$uri => true] + ); + + $session->expects($this->once())->method('set')->with('resource_subscriptions', [$uri => true]); + $session->expects($this->once())->method('save'); + + $this->subscriptionManager->subscribe($session, $uri); + + // Create a concrete exception that implements InvalidArgumentException + $exception = new class('Cache error') extends \Exception implements InvalidArgumentException {}; + + $protocol->expects($this->once()) + ->method('sendNotification') + ->willThrowException($exception); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + 'Error sending resource notification to session', + $this->callback(static function ($context) use ($uuid, $uri, $exception) { + return $context['session_id'] === (string) $uuid + && $context['uri'] === $uri + && $context['exception'] === $exception; + }) + ); + + try { + // Act + $this->subscriptionManager->notifyResourceChanged($protocol, $session, $uri); + + $this->fail('Expected an exception to be thrown.'); + } catch (InvalidArgumentException $e) { + // Assert + $this->assertSame($exception, $e); + + return; + } + } +}