diff --git a/CHANGELOG.md b/CHANGELOG.md index 31e3be7f..5c8b825e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `mcp/sdk` will be documented in this file. ----- * Rename `Mcp\Server\Session\Psr16StoreSession` to `Mcp\Server\Session\Psr16SessionStore` +* Introduce `SessionManager` to encapsulate session handling (replaces `SessionFactory`) and move garbage collection logic from `Protocol`. 0.3.0 ----- diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 9e9b6b2f..fd3b0631 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -23,6 +23,7 @@ use Mcp\Capability\Registry\Loader\LoaderInterface; use Mcp\Capability\Registry\ReferenceHandler; use Mcp\Capability\RegistryInterface; +use Mcp\Exception\InvalidArgumentException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; @@ -34,8 +35,8 @@ use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Session\InMemorySessionStore; -use Mcp\Server\Session\SessionFactory; -use Mcp\Server\Session\SessionFactoryInterface; +use Mcp\Server\Session\SessionManager; +use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; @@ -66,12 +67,10 @@ final class Builder private ?DiscovererInterface $discoverer = null; - private ?SessionFactoryInterface $sessionFactory = null; + private ?SessionManagerInterface $sessionManager = null; private ?SessionStoreInterface $sessionStore = null; - private int $sessionTtl = 3600; - private int $paginationLimit = 50; private ?string $instructions = null; @@ -310,13 +309,15 @@ public function setDiscoverer(DiscovererInterface $discoverer): self } public function setSession( - SessionStoreInterface $sessionStore, - SessionFactoryInterface $sessionFactory = new SessionFactory(), - int $ttl = 3600, + ?SessionStoreInterface $sessionStore = null, + ?SessionManagerInterface $sessionManager = null, ): self { - $this->sessionFactory = $sessionFactory; $this->sessionStore = $sessionStore; - $this->sessionTtl = $ttl; + $this->sessionManager = $sessionManager; + + if (null !== $sessionManager && null !== $sessionStore) { + throw new InvalidArgumentException('Cannot set both SessionStore and SessionManager. Set only one or the other.'); + } return $this; } @@ -504,9 +505,10 @@ public function build(): Server $loader->load($registry); } - $sessionTtl = $this->sessionTtl ?? 3600; - $sessionFactory = $this->sessionFactory ?? new SessionFactory(); - $sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl); + $sessionManager = $this->sessionManager ?? new SessionManager( + $this->sessionStore ?? new InMemorySessionStore(), + $logger, + ); $messageFactory = MessageFactory::make(); $capabilities = $this->serverCapabilities ?? new ServerCapabilities( @@ -547,8 +549,7 @@ public function build(): Server requestHandlers: $requestHandlers, notificationHandlers: $notificationHandlers, messageFactory: $messageFactory, - sessionFactory: $sessionFactory, - sessionStore: $sessionStore, + sessionManager: $sessionManager, logger: $logger, ); diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index feedae3b..3a880ab4 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -21,9 +21,8 @@ use Mcp\Schema\Request\InitializeRequest; use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; -use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionInterface; -use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Transport\TransportInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -65,8 +64,7 @@ public function __construct( private readonly array $requestHandlers, private readonly array $notificationHandlers, private readonly MessageFactory $messageFactory, - private readonly SessionFactoryInterface $sessionFactory, - private readonly SessionStoreInterface $sessionStore, + private readonly SessionManagerInterface $sessionManager, private readonly LoggerInterface $logger = new NullLogger(), ) { } @@ -106,7 +104,7 @@ public function processInput(TransportInterface $transport, string $input, ?Uuid { $this->logger->info('Received message to process.', ['message' => $input]); - $this->gcSessions(); + $this->sessionManager->gc(); try { $messages = $this->messageFactory->create($input); @@ -367,7 +365,7 @@ private function queueOutgoing(Request|Notification|Response|Error $message, arr */ public function consumeOutgoingMessages(Uuid $sessionId): array { - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $session = $this->sessionManager->createWithId($sessionId); $queue = $session->get(self::SESSION_OUTGOING_QUEUE, []); $session->set(self::SESSION_OUTGOING_QUEUE, []); $session->save(); @@ -386,7 +384,7 @@ public function consumeOutgoingMessages(Uuid $sessionId): array */ public function checkResponse(int $requestId, Uuid $sessionId): Response|Error|null { - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $session = $this->sessionManager->createWithId($sessionId); $responseData = $session->get(self::SESSION_RESPONSES.".{$requestId}"); if (null === $responseData) { @@ -428,7 +426,7 @@ public function checkResponse(int $requestId, Uuid $sessionId): Response|Error|n */ public function getPendingRequests(Uuid $sessionId): array { - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $session = $this->sessionManager->createWithId($sessionId); return $session->get(self::SESSION_PENDING_REQUESTS, []); } @@ -455,7 +453,7 @@ public function handleFiberYield(mixed $yieldedValue, ?Uuid $sessionId): void return; } - $session = $this->sessionFactory->createWithId($sessionId, $this->sessionStore); + $session = $this->sessionManager->createWithId($sessionId); $payloadSessionId = $yieldedValue['session_id'] ?? null; if (\is_string($payloadSessionId) && $payloadSessionId !== $sessionId->toRfc4122()) { @@ -539,7 +537,7 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, return null; } - $session = $this->sessionFactory->create($this->sessionStore); + $session = $this->sessionManager->create(); $this->logger->debug('Created new session for initialize', [ 'session_id' => $session->getId()->toRfc4122(), ]); @@ -556,33 +554,14 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, return null; } - if (!$this->sessionStore->exists($sessionId)) { + if (!$this->sessionManager->exists($sessionId)) { $error = Error::forInvalidRequest('Session not found or has expired.'); $this->sendResponse($transport, $error, null, ['status_code' => 404]); return null; } - return $this->sessionFactory->createWithId($sessionId, $this->sessionStore); - } - - /** - * Run garbage collection on expired sessions. - * Uses the session store's internal TTL configuration. - */ - private function gcSessions(): void - { - if (random_int(0, 100) > 1) { - return; - } - - $deletedSessions = $this->sessionStore->gc(); - if (!empty($deletedSessions)) { - $this->logger->debug('Garbage collected expired sessions.', [ - 'count' => \count($deletedSessions), - 'session_ids' => array_map(static fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), - ]); - } + return $this->sessionManager->createWithId($sessionId); } /** @@ -590,7 +569,7 @@ private function gcSessions(): void */ public function destroySession(Uuid $sessionId): void { - $this->sessionStore->destroy($sessionId); + $this->sessionManager->destroy($sessionId); $this->logger->info('Session destroyed.', ['session_id' => $sessionId->toRfc4122()]); } } diff --git a/src/Server/Session/Session.php b/src/Server/Session/Session.php index 3ca8d808..6782d1c5 100644 --- a/src/Server/Session/Session.php +++ b/src/Server/Session/Session.php @@ -44,11 +44,6 @@ public function getId(): Uuid return $this->id; } - public function getStore(): SessionStoreInterface - { - return $this->store; - } - public function save(): bool { return $this->store->write($this->id, json_encode($this->data, \JSON_THROW_ON_ERROR)); diff --git a/src/Server/Session/SessionFactory.php b/src/Server/Session/SessionFactory.php deleted file mode 100644 index 0064ae4c..00000000 --- a/src/Server/Session/SessionFactory.php +++ /dev/null @@ -1,32 +0,0 @@ - - */ -class SessionFactory implements SessionFactoryInterface -{ - public function create(SessionStoreInterface $store): SessionInterface - { - return new Session($store, Uuid::v4()); - } - - public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface - { - return new Session($store, $id); - } -} diff --git a/src/Server/Session/SessionInterface.php b/src/Server/Session/SessionInterface.php index 8f93a6e4..e14d2c63 100644 --- a/src/Server/Session/SessionInterface.php +++ b/src/Server/Session/SessionInterface.php @@ -77,9 +77,4 @@ public function all(): array; * @param array $attributes */ public function hydrate(array $attributes): void; - - /** - * Get the session store instance. - */ - public function getStore(): SessionStoreInterface; } diff --git a/src/Server/Session/SessionManager.php b/src/Server/Session/SessionManager.php new file mode 100644 index 00000000..d41e1934 --- /dev/null +++ b/src/Server/Session/SessionManager.php @@ -0,0 +1,69 @@ + + */ +class SessionManager implements SessionManagerInterface +{ + public function __construct( + private readonly SessionStoreInterface $store, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + public function create(): SessionInterface + { + return new Session($this->store, Uuid::v4()); + } + + public function createWithId(Uuid $id): SessionInterface + { + return new Session($this->store, $id); + } + + public function exists(Uuid $id): bool + { + return $this->store->exists($id); + } + + public function destroy(Uuid $sessionId): bool + { + return $this->store->destroy($sessionId); + } + + /** + * Run garbage collection on expired sessions. + * Uses the session store's internal TTL configuration. + */ + public function gc(): void + { + if (random_int(0, 100) > 1) { + return; + } + + $deletedSessions = $this->store->gc(); + if (!empty($deletedSessions)) { + $this->logger->debug('Garbage collected expired sessions.', [ + 'count' => \count($deletedSessions), + 'session_ids' => array_map(static fn (Uuid $id) => $id->toRfc4122(), $deletedSessions), + ]); + } + } +} diff --git a/src/Server/Session/SessionFactoryInterface.php b/src/Server/Session/SessionManagerInterface.php similarity index 66% rename from src/Server/Session/SessionFactoryInterface.php rename to src/Server/Session/SessionManagerInterface.php index 15343346..2ecbbca9 100644 --- a/src/Server/Session/SessionFactoryInterface.php +++ b/src/Server/Session/SessionManagerInterface.php @@ -19,17 +19,29 @@ * * @author Kyrian Obikwelu */ -interface SessionFactoryInterface +interface SessionManagerInterface { /** * Creates a new session with an auto-generated UUID. * This is the standard factory method for creating sessions. */ - public function create(SessionStoreInterface $store): SessionInterface; + public function create(): SessionInterface; /** * Creates a session with a specific UUID. * Use this when you need to reconstruct a session with a known ID. */ - public function createWithId(Uuid $id, SessionStoreInterface $store): SessionInterface; + public function createWithId(Uuid $id): SessionInterface; + + /** + * Checks if a session with the given UUID exists. + */ + public function exists(Uuid $id): bool; + + /** + * Destroys the session with the given UUID. + */ + public function destroy(Uuid $sessionId): bool; + + public function gc(): void; } diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index c73307bc..d003bde4 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -17,9 +17,8 @@ use Mcp\Server\Handler\Notification\NotificationHandlerInterface; use Mcp\Server\Handler\Request\RequestHandlerInterface; use Mcp\Server\Protocol; -use Mcp\Server\Session\SessionFactoryInterface; use Mcp\Server\Session\SessionInterface; -use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Transport\TransportInterface; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\MockObject\MockObject; @@ -28,15 +27,13 @@ final class ProtocolTest extends TestCase { - private MockObject&SessionFactoryInterface $sessionFactory; - private MockObject&SessionStoreInterface $sessionStore; + private MockObject&SessionManagerInterface $sessionManager; /** @var MockObject&TransportInterface */ private MockObject&TransportInterface $transport; protected function setUp(): void { - $this->sessionFactory = $this->createMock(SessionFactoryInterface::class); - $this->sessionStore = $this->createMock(SessionStoreInterface::class); + $this->sessionManager = $this->createMock(SessionManagerInterface::class); $this->transport = $this->createMock(TransportInterface::class); } @@ -57,15 +54,14 @@ public function testNotificationHandledByMultipleHandlers(): void $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [], notificationHandlers: [$handlerA, $handlerB, $handlerC], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -93,8 +89,8 @@ public function testRequestHandledByFirstMatchingHandler(): void $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $session->method('getId')->willReturn(Uuid::v4()); // Configure session mock for queue operations @@ -122,8 +118,7 @@ public function testRequestHandledByFirstMatchingHandler(): void requestHandlers: [$handlerA, $handlerB, $handlerC], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -160,8 +155,7 @@ public function testInitializeRequestWithSessionIdReturnsError(): void requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -191,8 +185,7 @@ public function testInitializeRequestInBatchReturnsError(): void requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->processInput( @@ -223,8 +216,7 @@ public function testNonInitializeRequestWithoutSessionIdReturnsError(): void requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->processInput( @@ -237,7 +229,7 @@ public function testNonInitializeRequestWithoutSessionIdReturnsError(): void #[TestDox('Non-existent session ID returns error')] public function testNonExistentSessionIdReturnsError(): void { - $this->sessionStore->method('exists')->willReturn(false); + $this->sessionManager->method('exists')->willReturn(false); $this->transport->expects($this->once()) ->method('send') @@ -257,8 +249,7 @@ public function testNonExistentSessionIdReturnsError(): void requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -288,8 +279,7 @@ public function testInvalidJsonReturnsParseError(): void requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->processInput( @@ -304,8 +294,8 @@ public function testInvalidMessageStructureReturnsError(): void { $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -332,8 +322,7 @@ public function testInvalidMessageStructureReturnsError(): void requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -357,8 +346,8 @@ public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void { $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -385,8 +374,7 @@ public function testRequestWithoutHandlerReturnsMethodNotFoundError(): void requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -415,8 +403,8 @@ public function testHandlerInvalidArgumentReturnsInvalidParamsError(): void $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -443,8 +431,7 @@ public function testHandlerInvalidArgumentReturnsInvalidParamsError(): void requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -473,8 +460,8 @@ public function testHandlerUnexpectedExceptionReturnsInternalError(): void $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -501,8 +488,7 @@ public function testHandlerUnexpectedExceptionReturnsInternalError(): void requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -531,15 +517,14 @@ public function testNotificationHandlerExceptionsAreCaught(): void $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $protocol = new Protocol( requestHandlers: [], notificationHandlers: [$handler], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -563,8 +548,8 @@ public function testSuccessfulRequestReturnsResponseWithSessionId(): void $session = $this->createMock(SessionInterface::class); $session->method('getId')->willReturn($sessionId); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // Configure session mock for queue operations $queue = []; @@ -591,8 +576,7 @@ public function testSuccessfulRequestReturnsResponseWithSessionId(): void requestHandlers: [$handler], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->processInput( @@ -642,8 +626,8 @@ public function testBatchRequestsAreProcessed(): void } }); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); // The protocol now queues responses instead of sending them directly $session->expects($this->exactly(2)) @@ -653,8 +637,7 @@ public function testBatchRequestsAreProcessed(): void requestHandlers: [$handlerA], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -679,8 +662,8 @@ public function testSessionIsSavedAfterProcessing(): void { $session = $this->createMock(SessionInterface::class); - $this->sessionFactory->method('createWithId')->willReturn($session); - $this->sessionStore->method('exists')->willReturn(true); + $this->sessionManager->method('createWithId')->willReturn($session); + $this->sessionManager->method('exists')->willReturn(true); $session->expects($this->once())->method('save'); @@ -688,8 +671,7 @@ public function testSessionIsSavedAfterProcessing(): void requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $sessionId = Uuid::v4(); @@ -705,7 +687,7 @@ public function testDestroySessionRemovesSession(): void { $sessionId = Uuid::v4(); - $this->sessionStore->expects($this->once()) + $this->sessionManager->expects($this->once()) ->method('destroy') ->with($sessionId); @@ -713,8 +695,7 @@ public function testDestroySessionRemovesSession(): void requestHandlers: [], notificationHandlers: [], messageFactory: MessageFactory::make(), - sessionFactory: $this->sessionFactory, - sessionStore: $this->sessionStore, + sessionManager: $this->sessionManager, ); $protocol->destroySession($sessionId); diff --git a/tests/Unit/Server/Session/SessionTest.php b/tests/Unit/Server/Session/SessionTest.php index 01e88bc3..38c07699 100644 --- a/tests/Unit/Server/Session/SessionTest.php +++ b/tests/Unit/Server/Session/SessionTest.php @@ -14,9 +14,264 @@ use Mcp\Server\Session\InMemorySessionStore; use Mcp\Server\Session\Session; use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\UuidV4; class SessionTest extends TestCase { + private InMemorySessionStore $store; + private Session $session; + + protected function setUp(): void + { + $this->store = new InMemorySessionStore(); + $this->session = new Session($this->store); + } + + public function testGetIdReturnsSessionId(): void + { + $id = new UuidV4(); + $session = new Session($this->store, $id); + + $this->assertSame($id, $session->getId()); + } + + public function testSetAndGetSimpleKey(): void + { + $this->session->set('foo', 'bar'); + + $this->assertSame('bar', $this->session->get('foo')); + } + + public function testGetReturnsDefaultWhenKeyDoesNotExist(): void + { + $this->assertNull($this->session->get('nonexistent')); + $this->assertSame('default', $this->session->get('nonexistent', 'default')); + } + + public function testSetAndGetNestedKey(): void + { + $this->session->set('user.name', 'John'); + $this->session->set('user.email', 'john@example.com'); + $this->session->set('user.address.city', 'New York'); + + $this->assertSame('John', $this->session->get('user.name')); + $this->assertSame('john@example.com', $this->session->get('user.email')); + $this->assertSame('New York', $this->session->get('user.address.city')); + $this->assertSame(['city' => 'New York'], $this->session->get('user.address')); + } + + public function testSetDoesNotOverwriteWhenOverwriteIsFalse(): void + { + $this->session->set('key', 'original'); + $this->session->set('key', 'new', overwrite: false); + + $this->assertSame('original', $this->session->get('key')); + } + + public function testSetOverwritesWhenOverwriteIsTrue(): void + { + $this->session->set('key', 'original'); + $this->session->set('key', 'new'); + + $this->assertSame('new', $this->session->get('key')); + } + + public function testHasReturnsTrueForExistingKey(): void + { + $this->session->set('foo', 'bar'); + + $this->assertTrue($this->session->has('foo')); + } + + public function testHasReturnsFalseForNonExistingKey(): void + { + $this->assertFalse($this->session->has('nonexistent')); + } + + public function testHasWorksWithNestedKeys(): void + { + $this->session->set('user.name', 'John'); + + $this->assertTrue($this->session->has('user')); + $this->assertTrue($this->session->has('user.name')); + $this->assertFalse($this->session->has('user.email')); + $this->assertFalse($this->session->has('user.name.first')); + } + + public function testForgetRemovesSimpleKey(): void + { + $this->session->set('foo', 'bar'); + $this->session->forget('foo'); + + $this->assertFalse($this->session->has('foo')); + $this->assertNull($this->session->get('foo')); + } + + public function testForgetRemovesNestedKey(): void + { + $this->session->set('user.name', 'John'); + $this->session->set('user.email', 'john@example.com'); + $this->session->forget('user.name'); + + $this->assertFalse($this->session->has('user.name')); + $this->assertTrue($this->session->has('user.email')); + } + + public function testClearRemovesAllData(): void + { + $this->session->set('foo', 'bar'); + $this->session->set('baz', 'qux'); + $this->session->clear(); + + $this->assertSame([], $this->session->all()); + $this->assertFalse($this->session->has('foo')); + $this->assertFalse($this->session->has('baz')); + } + + public function testPullReturnsValueAndRemovesKey(): void + { + $this->session->set('foo', 'bar'); + + $value = $this->session->pull('foo'); + + $this->assertSame('bar', $value); + $this->assertFalse($this->session->has('foo')); + } + + public function testPullReturnsDefaultWhenKeyDoesNotExist(): void + { + $this->assertNull($this->session->pull('nonexistent')); + $this->assertSame('default', $this->session->pull('also_nonexistent', 'default')); + } + + public function testAllReturnsAllData(): void + { + $this->session->set('foo', 'bar'); + $this->session->set('user.name', 'John'); + + $all = $this->session->all(); + + $this->assertSame([ + 'foo' => 'bar', + 'user' => ['name' => 'John'], + ], $all); + } + + public function testHydrateReplacesAllData(): void + { + $this->session->set('original', 'value'); + + $this->session->hydrate(['new' => 'data', 'nested' => ['key' => 'value']]); + + $this->assertSame([ + 'new' => 'data', + 'nested' => ['key' => 'value'], + ], $this->session->all()); + $this->assertFalse($this->session->has('original')); + } + + public function testJsonSerializeReturnsAllData(): void + { + $this->session->set('foo', 'bar'); + $this->session->set('user.name', 'John'); + + $serialized = $this->session->jsonSerialize(); + + $this->assertSame([ + 'foo' => 'bar', + 'user' => ['name' => 'John'], + ], $serialized); + } + + public function testSavePersistsDataToStore(): void + { + $this->session->set('foo', 'bar'); + $result = $this->session->save(); + + $this->assertTrue($result); + + // Verify data was persisted by creating a new session with the same ID + $newSession = new Session($this->store, $this->session->getId()); + $this->assertSame('bar', $newSession->get('foo')); + } + + public function testSessionLoadsDataFromStoreOnConstruction(): void + { + // Set and save data in one session + $this->session->set('persisted', 'value'); + $this->session->save(); + $sessionId = $this->session->getId(); + + // Create a new session instance with the same ID + $newSession = new Session($this->store, $sessionId); + + $this->assertSame('value', $newSession->get('persisted')); + } + + public function testSetCreatesNestedStructure(): void + { + $this->session->set('a.b.c.d', 'value'); + + $this->assertSame('value', $this->session->get('a.b.c.d')); + $this->assertSame(['d' => 'value'], $this->session->get('a.b.c')); + $this->assertSame(['c' => ['d' => 'value']], $this->session->get('a.b')); + $this->assertSame(['b' => ['c' => ['d' => 'value']]], $this->session->get('a')); + } + + public function testSetOverwritesNonArrayWithNestedStructure(): void + { + $this->session->set('key', 'string_value'); + $this->session->set('key.nested', 'nested_value'); + + $this->assertSame('nested_value', $this->session->get('key.nested')); + $this->assertSame(['nested' => 'nested_value'], $this->session->get('key')); + } + + public function testGetReturnsArrayForIntermediateKey(): void + { + $this->session->set('user.profile.name', 'John'); + $this->session->set('user.profile.age', 30); + + $profile = $this->session->get('user.profile'); + + $this->assertSame(['name' => 'John', 'age' => 30], $profile); + } + + public function testForgetDoesNotThrowWhenKeyDoesNotExist(): void + { + $this->session->forget('nonexistent'); + $this->session->forget('nested.nonexistent'); + + $this->assertFalse($this->session->has('nonexistent')); + } + + public function testSessionCanStoreVariousDataTypes(): void + { + $this->session->set('string', 'value'); + $this->session->set('int', 42); + $this->session->set('float', 3.14); + $this->session->set('bool', true); + $this->session->set('null', null); + $this->session->set('array', ['a', 'b', 'c']); + $this->session->set('assoc', ['key' => 'value']); + + $this->assertSame('value', $this->session->get('string')); + $this->assertSame(42, $this->session->get('int')); + $this->assertSame(3.14, $this->session->get('float')); + $this->assertTrue($this->session->get('bool')); + $this->assertNull($this->session->get('null')); + $this->assertSame(['a', 'b', 'c'], $this->session->get('array')); + $this->assertSame(['key' => 'value'], $this->session->get('assoc')); + } + + public function testSessionGeneratesUniqueIdIfNotProvided(): void + { + $session1 = new Session($this->store); + $session2 = new Session($this->store); + + $this->assertNotEquals($session1->getId()->toRfc4122(), $session2->getId()->toRfc4122()); + } + public function testAll() { $store = $this->getMockBuilder(InMemorySessionStore::class)