store = new InMemoryUploadSessionStore(); // Create RandomGenerator for UploadSessionId generation $this->randomGen = new class implements RandomGenerator { public function bytes(int $length): string { return random_bytes($length); } public function int(int $min, int $max): int { return random_int($min, $max); } public function float(): float { return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX; } }; $this->sessionIdGenerator = new UploadSessionIdGenerator($this->randomGen); }); it('saves and retrieves session', function () { $sessionId = $this->sessionIdGenerator->generate(); $session = UploadSession::create( sessionId: $sessionId, componentId: 'test-component', fileName: 'test.pdf', totalSize: Byte::fromMegabytes(5), totalChunks: 10 ); $this->store->save($session); $retrieved = $this->store->get($sessionId); expect($retrieved !== null)->toBeTrue(); expect($retrieved->sessionId->equals($sessionId))->toBeTrue(); expect($retrieved->fileName)->toBe('test.pdf'); expect($retrieved->totalChunks)->toBe(10); }); it('returns null for non-existent session', function () { $sessionId = $this->sessionIdGenerator->generate(); $retrieved = $this->store->get($sessionId); expect($retrieved)->toBeNull(); }); it('checks session existence correctly', function () { $sessionId = $this->sessionIdGenerator->generate(); $session = UploadSession::create( sessionId: $sessionId, componentId: 'test-component', fileName: 'test.pdf', totalSize: Byte::fromMegabytes(5), totalChunks: 10 ); expect($this->store->exists($sessionId))->toBeFalse(); $this->store->save($session); expect($this->store->exists($sessionId))->toBeTrue(); }); it('deletes session', function () { $sessionId = $this->sessionIdGenerator->generate(); $session = UploadSession::create( sessionId: $sessionId, componentId: 'test-component', fileName: 'test.pdf', totalSize: Byte::fromMegabytes(5), totalChunks: 10 ); $this->store->save($session); expect($this->store->exists($sessionId))->toBeTrue(); $this->store->delete($sessionId); expect($this->store->exists($sessionId))->toBeFalse(); expect($this->store->get($sessionId))->toBeNull(); }); it('deletes non-existent session without error', function () { $sessionId = $this->sessionIdGenerator->generate(); expect($this->store->exists($sessionId))->toBeFalse(); $this->store->delete($sessionId); expect($this->store->exists($sessionId))->toBeFalse(); }); it('updates existing session', function () { $sessionId = $this->sessionIdGenerator->generate(); $session = UploadSession::create( sessionId: $sessionId, componentId: 'test-component', fileName: 'test.pdf', totalSize: Byte::fromMegabytes(5), totalChunks: 10 ); $this->store->save($session); // Add chunk to session $chunk = ChunkMetadata::create( index: 0, size: Byte::fromKilobytes(512), hash: ChunkHash::fromData('chunk data') ); $updatedSession = $session->withChunk($chunk); $this->store->save($updatedSession); $retrieved = $this->store->get($sessionId); expect($retrieved !== null)->toBeTrue(); expect(count($retrieved->chunks))->toBe(1); expect($retrieved->chunks[0]->index)->toBe(0); }); it('cleans up expired sessions', function () { // Create expired session (TTL in past) $expiredSessionId = $this->sessionIdGenerator->generate(); $expiredSession = new UploadSession( sessionId: $expiredSessionId, componentId: 'test-component', fileName: 'expired.pdf', totalSize: Byte::fromMegabytes(5), totalChunks: 10, createdAt: new DateTimeImmutable('-2 hours'), expiresAt: new DateTimeImmutable('-1 hour') ); // Create valid session $validSessionId = $this->sessionIdGenerator->generate(); $validSession = UploadSession::create( sessionId: $validSessionId, componentId: 'test-component', fileName: 'valid.pdf', totalSize: Byte::fromMegabytes(5), totalChunks: 10 ); $this->store->save($expiredSession); $this->store->save($validSession); expect($this->store->count())->toBe(2); $cleaned = $this->store->cleanupExpired(); expect($cleaned)->toBe(1); expect($this->store->count())->toBe(1); expect($this->store->exists($expiredSessionId))->toBeFalse(); expect($this->store->exists($validSessionId))->toBeTrue(); }); it('cleanup returns zero when no expired sessions', function () { $sessionId = $this->sessionIdGenerator->generate(); $session = UploadSession::create( sessionId: $sessionId, componentId: 'test-component', fileName: 'test.pdf', totalSize: Byte::fromMegabytes(5), totalChunks: 10 ); $this->store->save($session); $cleaned = $this->store->cleanupExpired(); expect($cleaned)->toBe(0); expect($this->store->count())->toBe(1); }); it('cleanup works on empty store', function () { expect($this->store->count())->toBe(0); $cleaned = $this->store->cleanupExpired(); expect($cleaned)->toBe(0); expect($this->store->count())->toBe(0); }); it('handles multiple sessions', function () { $sessions = []; for ($i = 0; $i < 5; $i++) { $sessionId = $this->sessionIdGenerator->generate(); $session = UploadSession::create( sessionId: $sessionId, componentId: "component-{$i}", fileName: "file-{$i}.pdf", totalSize: Byte::fromMegabytes($i + 1), totalChunks: ($i + 1) * 2 ); $sessions[] = $session; $this->store->save($session); } expect($this->store->count())->toBe(5); foreach ($sessions as $session) { $retrieved = $this->store->get($session->sessionId); expect($retrieved !== null)->toBeTrue(); expect($retrieved->sessionId->equals($session->sessionId))->toBeTrue(); } }); it('getAll returns all sessions as array', function () { $session1 = UploadSession::create( sessionId: $this->sessionIdGenerator->generate(), componentId: 'component-1', fileName: 'file1.pdf', totalSize: Byte::fromMegabytes(1), totalChunks: 2 ); $session2 = UploadSession::create( sessionId: $this->sessionIdGenerator->generate(), componentId: 'component-2', fileName: 'file2.pdf', totalSize: Byte::fromMegabytes(2), totalChunks: 4 ); $this->store->save($session1); $this->store->save($session2); $allSessions = $this->store->getAll(); expect($allSessions)->toBeArray(); expect(count($allSessions))->toBe(2); expect($allSessions[0])->toBeInstanceOf(UploadSession::class); expect($allSessions[1])->toBeInstanceOf(UploadSession::class); }); it('count returns correct session count', function () { expect($this->store->count())->toBe(0); $session1 = UploadSession::create( sessionId: $this->sessionIdGenerator->generate(), componentId: 'component-1', fileName: 'file1.pdf', totalSize: Byte::fromMegabytes(1), totalChunks: 2 ); $this->store->save($session1); expect($this->store->count())->toBe(1); $session2 = UploadSession::create( sessionId: $this->sessionIdGenerator->generate(), componentId: 'component-2', fileName: 'file2.pdf', totalSize: Byte::fromMegabytes(2), totalChunks: 4 ); $this->store->save($session2); expect($this->store->count())->toBe(2); $this->store->delete($session1->sessionId); expect($this->store->count())->toBe(1); }); it('clear removes all sessions', function () { for ($i = 0; $i < 3; $i++) { $session = UploadSession::create( sessionId: $this->sessionIdGenerator->generate(), componentId: "component-{$i}", fileName: "file-{$i}.pdf", totalSize: Byte::fromMegabytes($i + 1), totalChunks: ($i + 1) * 2 ); $this->store->save($session); } expect($this->store->count())->toBe(3); $this->store->clear(); expect($this->store->count())->toBe(0); expect($this->store->getAll())->toBe([]); }); it('handles session with quarantine status', function () { $sessionId = $this->sessionIdGenerator->generate(); $session = UploadSession::create( sessionId: $sessionId, componentId: 'test-component', fileName: 'test.pdf', totalSize: Byte::fromMegabytes(5), totalChunks: 10 ); // Proper lifecycle: PENDING → SCANNING → APPROVED $scanningSession = $session->withQuarantineStatus(QuarantineStatus::SCANNING); $approvedSession = $scanningSession->withQuarantineStatus(QuarantineStatus::APPROVED); $this->store->save($approvedSession); $retrieved = $this->store->get($sessionId); expect($retrieved !== null)->toBeTrue(); expect($retrieved->quarantineStatus)->toBe(QuarantineStatus::APPROVED); }); it('handles session with chunks', function () { $sessionId = $this->sessionIdGenerator->generate(); $session = UploadSession::create( sessionId: $sessionId, componentId: 'test-component', fileName: 'test.pdf', totalSize: Byte::fromMegabytes(5), totalChunks: 3 ); // Add chunks $chunk1 = ChunkMetadata::create( index: 0, size: Byte::fromKilobytes(512), hash: ChunkHash::fromData('chunk 0 data') ); $chunk2 = ChunkMetadata::create( index: 1, size: Byte::fromKilobytes(512), hash: ChunkHash::fromData('chunk 1 data') ); $sessionWithChunks = $session->withChunk($chunk1)->withChunk($chunk2); $this->store->save($sessionWithChunks); $retrieved = $this->store->get($sessionId); expect($retrieved !== null)->toBe(true); expect(count($retrieved->chunks))->toBe(2); }); it('isolates sessions by different session IDs', function () { $sessionId1 = $this->sessionIdGenerator->generate(); $sessionId2 = $this->sessionIdGenerator->generate(); $session1 = UploadSession::create( sessionId: $sessionId1, componentId: 'component-1', fileName: 'file1.pdf', totalSize: Byte::fromMegabytes(1), totalChunks: 2 ); $session2 = UploadSession::create( sessionId: $sessionId2, componentId: 'component-2', fileName: 'file2.pdf', totalSize: Byte::fromMegabytes(2), totalChunks: 4 ); $this->store->save($session1); $this->store->save($session2); $retrieved1 = $this->store->get($sessionId1); $retrieved2 = $this->store->get($sessionId2); expect($retrieved1->fileName)->toBe('file1.pdf'); expect($retrieved2->fileName)->toBe('file2.pdf'); expect($retrieved1->totalChunks)->toBe(2); expect($retrieved2->totalChunks)->toBe(4); }); });