testDir = sys_get_temp_dir() . '/chunked_upload_test_' . uniqid(); mkdir($this->testDir); // Setup dependencies $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); $this->sessionStore = new InMemoryUploadSessionStore(); $this->integrityValidator = new IntegrityValidator(); $this->fileStorage = new FileStorage(); $this->chunkAssembler = new ChunkAssembler($this->fileStorage); $this->progressTracker = new InMemoryUploadProgressTracker(); $this->uploadManager = new ChunkedUploadManager( sessionIdGenerator: $this->sessionIdGenerator, sessionStore: $this->sessionStore, integrityValidator: $this->integrityValidator, chunkAssembler: $this->chunkAssembler, fileStorage: $this->fileStorage, progressTracker: $this->progressTracker, uploadBasePath: $this->testDir ); }); afterEach(function () { // Cleanup test directory if (is_dir($this->testDir)) { $files = glob($this->testDir . '/*'); foreach ($files as $file) { if (is_file($file)) { unlink($file); } elseif (is_dir($file)) { $subFiles = glob($file . '/*'); foreach ($subFiles as $subFile) { if (is_file($subFile)) { unlink($subFile); } } rmdir($file); } } rmdir($this->testDir); } }); it('initializes upload session', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.pdf', totalSize: Byte::fromKilobytes(10), chunkSize: Byte::fromKilobytes(2) ); expect($session->componentId)->toBe('test-component'); expect($session->fileName)->toBe('test.pdf'); expect($session->totalSize->toKilobytes())->toBe(10.0); expect($session->totalChunks)->toBe(5); // 10KB / 2KB = 5 chunks expect($session->isComplete())->toBe(false); // Verify session is persisted $retrieved = $this->uploadManager->getStatus($session->sessionId); expect($retrieved !== null)->toBe(true); expect($retrieved->sessionId->equals($session->sessionId))->toBeTrue(); }); it('initializes with SSE broadcast', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.pdf', totalSize: Byte::fromKilobytes(10), chunkSize: Byte::fromKilobytes(2), userId: 'user123' ); $broadcasts = $this->progressTracker->getBroadcasts(); expect(count($broadcasts))->toBe(1); expect($broadcasts[0]['type'])->toBe('initialized'); expect($broadcasts[0]['user_id'])->toBe('user123'); }); it('uploads chunks sequentially', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(300), chunkSize: Byte::fromBytes(100) ); $chunk1Data = str_repeat('A', 100); $chunk1Hash = ChunkHash::fromData($chunk1Data); $updatedSession = $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 0, chunkData: $chunk1Data, providedHash: $chunk1Hash ); expect(count($updatedSession->getUploadedChunks()))->toBe(1); expect($updatedSession->getProgress())->toBeGreaterThan(0); expect($updatedSession->isComplete())->toBe(false); // Upload second chunk $chunk2Data = str_repeat('B', 100); $chunk2Hash = ChunkHash::fromData($chunk2Data); $updatedSession = $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 1, chunkData: $chunk2Data, providedHash: $chunk2Hash ); expect(count($updatedSession->getUploadedChunks()))->toBe(2); }); it('uploads chunks with SSE broadcasts', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(200), chunkSize: Byte::fromBytes(100), userId: 'user123' ); $this->progressTracker->clear(); $chunk1Data = str_repeat('A', 100); $chunk1Hash = ChunkHash::fromData($chunk1Data); $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 0, chunkData: $chunk1Data, providedHash: $chunk1Hash, userId: 'user123' ); expect($this->progressTracker->getBroadcastCount('chunk_uploaded'))->toBe(1); }); it('completes upload and assembles file', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(300), chunkSize: Byte::fromBytes(100) ); // Upload all chunks for ($i = 0; $i < 3; $i++) { $chunkData = str_repeat(chr(65 + $i), 100); // A, B, C $chunkHash = ChunkHash::fromData($chunkData); $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: $i, chunkData: $chunkData, providedHash: $chunkHash ); } // Complete upload $targetPath = $this->testDir . '/assembled.txt'; $completedSession = $this->uploadManager->completeUpload( sessionId: $session->sessionId, targetPath: $targetPath ); expect($completedSession->isComplete())->toBe(true); expect($completedSession->completedAt !== null)->toBe(true); expect(file_exists($targetPath))->toBeTrue(); $content = file_get_contents($targetPath); expect(strlen($content))->toBe(300); expect(substr($content, 0, 100))->toBe(str_repeat('A', 100)); expect(substr($content, 100, 100))->toBe(str_repeat('B', 100)); expect(substr($content, 200, 100))->toBe(str_repeat('C', 100)); }); it('validates final file hash', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(200), chunkSize: Byte::fromBytes(100), expectedFileHash: ChunkHash::fromData(str_repeat('A', 100) . str_repeat('B', 100)) ); // Upload chunks $chunk1Data = str_repeat('A', 100); $chunk1Hash = ChunkHash::fromData($chunk1Data); $this->uploadManager->uploadChunk($session->sessionId, 0, $chunk1Data, $chunk1Hash); $chunk2Data = str_repeat('B', 100); $chunk2Hash = ChunkHash::fromData($chunk2Data); $this->uploadManager->uploadChunk($session->sessionId, 1, $chunk2Data, $chunk2Hash); // Complete upload - should succeed with matching hash $targetPath = $this->testDir . '/assembled.txt'; $completedSession = $this->uploadManager->completeUpload($session->sessionId, $targetPath); expect($completedSession->isComplete())->toBe(true); expect(file_exists($targetPath))->toBeTrue(); }); it('rejects mismatched final file hash', function () { $wrongHash = ChunkHash::fromData('wrong content'); $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(200), chunkSize: Byte::fromBytes(100), expectedFileHash: $wrongHash ); // Upload chunks $chunk1Data = str_repeat('A', 100); $chunk1Hash = ChunkHash::fromData($chunk1Data); $this->uploadManager->uploadChunk($session->sessionId, 0, $chunk1Data, $chunk1Hash); $chunk2Data = str_repeat('B', 100); $chunk2Hash = ChunkHash::fromData($chunk2Data); $this->uploadManager->uploadChunk($session->sessionId, 1, $chunk2Data, $chunk2Hash); // Complete upload - should fail with hash mismatch $targetPath = $this->testDir . '/assembled.txt'; expect(fn() => $this->uploadManager->completeUpload($session->sessionId, $targetPath)) ->toThrow(InvalidArgumentException::class); // Verify file was cleaned up after hash mismatch expect(file_exists($targetPath))->toBe(false); }); it('rejects chunk with invalid hash', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(200), chunkSize: Byte::fromBytes(100) ); $chunkData = str_repeat('A', 100); $wrongHash = ChunkHash::fromData('wrong data'); expect(fn() => $this->uploadManager->uploadChunk( $session->sessionId, 0, $chunkData, $wrongHash ))->toThrow(InvalidArgumentException::class); }); it('rejects chunk for non-existent session', function () { $nonExistentSessionId = $this->sessionIdGenerator->generate(); $chunkData = str_repeat('A', 100); $chunkHash = ChunkHash::fromData($chunkData); expect(fn() => $this->uploadManager->uploadChunk( $nonExistentSessionId, 0, $chunkData, $chunkHash ))->toThrow(InvalidArgumentException::class); }); it('rejects chunk for expired session', function () { $sessionId = $this->sessionIdGenerator->generate(); // Create expired session $expiredSession = new \App\Framework\LiveComponents\ValueObjects\UploadSession( sessionId: $sessionId, componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(200), totalChunks: 2, createdAt: new DateTimeImmutable('-2 hours'), expiresAt: new DateTimeImmutable('-1 hour') ); $this->sessionStore->save($expiredSession); $chunkData = str_repeat('A', 100); $chunkHash = ChunkHash::fromData($chunkData); expect(fn() => $this->uploadManager->uploadChunk( $sessionId, 0, $chunkData, $chunkHash ))->toThrow(InvalidArgumentException::class); }); it('rejects invalid chunk index', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(200), chunkSize: Byte::fromBytes(100) ); $chunkData = str_repeat('A', 100); $chunkHash = ChunkHash::fromData($chunkData); // Chunk index out of range (totalChunks = 2, so valid indices are 0,1) expect(fn() => $this->uploadManager->uploadChunk( $session->sessionId, 5, $chunkData, $chunkHash ))->toThrow(InvalidArgumentException::class); }); it('prevents completion with missing chunks', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(300), chunkSize: Byte::fromBytes(100) ); // Upload only first chunk (missing chunks 1 and 2) $chunkData = str_repeat('A', 100); $chunkHash = ChunkHash::fromData($chunkData); $this->uploadManager->uploadChunk($session->sessionId, 0, $chunkData, $chunkHash); $targetPath = $this->testDir . '/assembled.txt'; expect(fn() => $this->uploadManager->completeUpload($session->sessionId, $targetPath)) ->toThrow(InvalidArgumentException::class); }); it('aborts upload and cleans up', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(300), chunkSize: Byte::fromBytes(100) ); // Upload one chunk $chunkData = str_repeat('A', 100); $chunkHash = ChunkHash::fromData($chunkData); $this->uploadManager->uploadChunk($session->sessionId, 0, $chunkData, $chunkHash); // Abort upload $this->uploadManager->abortUpload($session->sessionId); // Verify session is deleted expect($this->uploadManager->getStatus($session->sessionId))->toBeNull(); // Verify chunks are cleaned up expect($this->sessionStore->exists($session->sessionId))->toBe(false); }); it('aborts with SSE broadcast', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(200), chunkSize: Byte::fromBytes(100), userId: 'user123' ); $this->progressTracker->clear(); $this->uploadManager->abortUpload($session->sessionId, userId: 'user123', reason: 'Test abort'); expect($this->progressTracker->getBroadcastCount('aborted'))->toBe(1); $abortBroadcasts = $this->progressTracker->getBroadcastsByType('aborted'); expect($abortBroadcasts[0]['data']['reason'])->toBe('Test abort'); }); it('handles resume capability', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(300), chunkSize: Byte::fromBytes(100) ); // Upload chunks 0 and 2 (skip chunk 1) $chunk0Data = str_repeat('A', 100); $chunk0Hash = ChunkHash::fromData($chunk0Data); $this->uploadManager->uploadChunk($session->sessionId, 0, $chunk0Data, $chunk0Hash); $chunk2Data = str_repeat('C', 100); $chunk2Hash = ChunkHash::fromData($chunk2Data); $this->uploadManager->uploadChunk($session->sessionId, 2, $chunk2Data, $chunk2Hash); // Get status to see missing chunks $currentSession = $this->uploadManager->getStatus($session->sessionId); $missingChunks = $currentSession->getMissingChunkIndices(); expect($missingChunks)->toBe([1]); // Upload missing chunk $chunk1Data = str_repeat('B', 100); $chunk1Hash = ChunkHash::fromData($chunk1Data); $this->uploadManager->uploadChunk($session->sessionId, 1, $chunk1Data, $chunk1Hash); // Now should be complete $finalSession = $this->uploadManager->getStatus($session->sessionId); expect($finalSession->isComplete())->toBe(true); }); it('creates session directory on initialization', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(200), chunkSize: Byte::fromBytes(100) ); $sessionPath = $this->testDir . '/' . $session->sessionId->toString(); expect(is_dir($sessionPath))->toBeTrue(); }); it('validates empty file size', function () { expect(fn() => $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(0), chunkSize: Byte::fromBytes(100) ))->toThrow(InvalidArgumentException::class); }); it('validates empty chunk size', function () { expect(fn() => $this->uploadManager->initializeUpload( componentId: 'test-component', fileName: 'test.txt', totalSize: Byte::fromBytes(200), chunkSize: Byte::fromBytes(0) ))->toThrow(InvalidArgumentException::class); }); });