sessionIdGenerator = new UploadSessionIdGenerator(); $this->sessionStore = new UploadSessionStore(); $this->integrityValidator = new IntegrityValidator(); $this->chunkAssembler = new ChunkAssembler(); $this->fileStorage = new InMemoryStorage(); // Mock progress tracker (no SSE in tests) $this->progressTracker = new class { public function broadcastInitialized($session, $userId): void {} public function broadcastChunkUploaded($session, $userId): void {} public function broadcastCompleted($session, $userId): void {} public function broadcastAborted($sessionId, $userId, $reason): void {} }; $this->uploadManager = new ChunkedUploadManager( $this->sessionIdGenerator, $this->sessionStore, $this->integrityValidator, $this->chunkAssembler, $this->fileStorage, $this->progressTracker, '/tmp/test-uploads' ); }); test('initializes upload session successfully', function () { $session = $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromKilobytes(1), chunkSize: Byte::fromBytes(512) ); expect($session->sessionId)->toBeInstanceOf(UploadSessionId::class); expect($session->componentId)->toBe('test-uploader'); expect($session->fileName)->toBe('test-file.txt'); expect($session->totalSize->toBytes())->toBe(1024); expect($session->totalChunks)->toBe(2); // 1024 bytes / 512 bytes = 2 chunks expect($session->isComplete())->toBeFalse(); }); test('rejects invalid file size', function () { expect(fn() => $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(0), chunkSize: Byte::fromBytes(512) ))->toThrow(\InvalidArgumentException::class, 'Total size must be greater than zero'); }); test('rejects invalid chunk size', function () { expect(fn() => $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromKilobytes(1), chunkSize: Byte::fromBytes(0) ))->toThrow(\InvalidArgumentException::class, 'Chunk size must be greater than zero'); }); test('uploads chunk successfully', function () { // Initialize session $session = $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(1024), chunkSize: Byte::fromBytes(512) ); // Prepare chunk data $chunkData = str_repeat('A', 512); $chunkHash = ChunkHash::fromData($chunkData); // Upload first chunk $updatedSession = $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 0, chunkData: $chunkData, providedHash: $chunkHash ); expect($updatedSession->getUploadedChunks())->toHaveCount(1); expect($updatedSession->getProgress())->toBe(50.0); // 1 of 2 chunks expect($updatedSession->isComplete())->toBeFalse(); }); test('rejects chunk with invalid hash', function () { // Initialize session $session = $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(1024), chunkSize: Byte::fromBytes(512) ); // Prepare chunk data with wrong hash $chunkData = str_repeat('A', 512); $wrongHash = ChunkHash::fromData('different data'); expect(fn() => $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 0, chunkData: $chunkData, providedHash: $wrongHash ))->toThrow(\InvalidArgumentException::class, 'hash mismatch'); }); test('rejects invalid chunk index', function () { // Initialize session $session = $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(1024), chunkSize: Byte::fromBytes(512) ); $chunkData = str_repeat('A', 512); $chunkHash = ChunkHash::fromData($chunkData); // Try to upload chunk with invalid index expect(fn() => $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 99, // Out of bounds chunkData: $chunkData, providedHash: $chunkHash ))->toThrow(\InvalidArgumentException::class, 'Invalid chunk index'); }); test('completes upload after all chunks uploaded', function () { // Initialize session $session = $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(1024), chunkSize: Byte::fromBytes(512) ); // Upload all chunks $chunk1Data = str_repeat('A', 512); $chunk1Hash = ChunkHash::fromData($chunk1Data); $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 0, chunkData: $chunk1Data, providedHash: $chunk1Hash ); $chunk2Data = str_repeat('B', 512); $chunk2Hash = ChunkHash::fromData($chunk2Data); $updatedSession = $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 1, chunkData: $chunk2Data, providedHash: $chunk2Hash ); expect($updatedSession->isComplete())->toBeTrue(); expect($updatedSession->getProgress())->toBe(100.0); // Complete upload $targetPath = '/tmp/final-file.txt'; $completedSession = $this->uploadManager->completeUpload( sessionId: $session->sessionId, targetPath: $targetPath ); expect($completedSession->completedAt)->not->toBeNull(); expect($this->fileStorage->exists($targetPath))->toBeTrue(); // Verify assembled file content $assembledContent = $this->fileStorage->get($targetPath); expect($assembledContent)->toBe($chunk1Data . $chunk2Data); }); test('rejects completion when chunks are missing', function () { // Initialize session $session = $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(1024), chunkSize: Byte::fromBytes(512) ); // Upload only first chunk (missing second chunk) $chunkData = str_repeat('A', 512); $chunkHash = ChunkHash::fromData($chunkData); $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 0, chunkData: $chunkData, providedHash: $chunkHash ); // Try to complete with missing chunks expect(fn() => $this->uploadManager->completeUpload( sessionId: $session->sessionId, targetPath: '/tmp/final-file.txt' ))->toThrow(\InvalidArgumentException::class, 'Upload incomplete'); }); test('aborts upload and cleans up', function () { // Initialize session $session = $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(1024), chunkSize: Byte::fromBytes(512) ); // Upload one chunk $chunkData = str_repeat('A', 512); $chunkHash = ChunkHash::fromData($chunkData); $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: 0, chunkData: $chunkData, providedHash: $chunkHash ); // Abort upload $this->uploadManager->abortUpload( sessionId: $session->sessionId, reason: 'User cancelled' ); // Verify session is deleted expect($this->uploadManager->getStatus($session->sessionId))->toBeNull(); }); test('tracks progress correctly', function () { // Initialize session with 4 chunks $session = $this->uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(2048), chunkSize: Byte::fromBytes(512) ); expect($session->getProgress())->toBe(0.0); // Upload chunks one by one for ($i = 0; $i < 4; $i++) { $chunkData = str_repeat(chr(65 + $i), 512); $chunkHash = ChunkHash::fromData($chunkData); $session = $this->uploadManager->uploadChunk( sessionId: $session->sessionId, chunkIndex: $i, chunkData: $chunkData, providedHash: $chunkHash ); $expectedProgress = (($i + 1) / 4) * 100; expect($session->getProgress())->toBe($expectedProgress); } expect($session->isComplete())->toBeTrue(); }); test('handles session not found', function () { $nonExistentSessionId = UploadSessionId::generate(); $chunkData = str_repeat('A', 512); $chunkHash = ChunkHash::fromData($chunkData); expect(fn() => $this->uploadManager->uploadChunk( sessionId: $nonExistentSessionId, chunkIndex: 0, chunkData: $chunkData, providedHash: $chunkHash ))->toThrow(\InvalidArgumentException::class, 'Session not found'); }); test('broadcasts progress with userId', function () { $broadcastCalled = false; // Replace progress tracker with capturing mock $this->progressTracker = new class($broadcastCalled) { public function __construct(private &$called) {} public function broadcastInitialized($session, $userId): void { if ($userId !== null) { $this->called = true; } } public function broadcastChunkUploaded($session, $userId): void {} public function broadcastCompleted($session, $userId): void {} public function broadcastAborted($sessionId, $userId, $reason): void {} }; $uploadManager = new ChunkedUploadManager( $this->sessionIdGenerator, $this->sessionStore, $this->integrityValidator, $this->chunkAssembler, $this->fileStorage, $this->progressTracker, '/tmp/test-uploads' ); // Initialize with userId $uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(1024), chunkSize: Byte::fromBytes(512), userId: 'user-123' ); expect($broadcastCalled)->toBeTrue(); }); test('skips SSE broadcast when userId is null', function () { $broadcastCalled = false; // Replace progress tracker with capturing mock $this->progressTracker = new class($broadcastCalled) { public function __construct(private &$called) {} public function broadcastInitialized($session, $userId): void { $this->called = true; } public function broadcastChunkUploaded($session, $userId): void {} public function broadcastCompleted($session, $userId): void {} public function broadcastAborted($sessionId, $userId, $reason): void {} }; $uploadManager = new ChunkedUploadManager( $this->sessionIdGenerator, $this->sessionStore, $this->integrityValidator, $this->chunkAssembler, $this->fileStorage, $this->progressTracker, '/tmp/test-uploads' ); // Initialize without userId $uploadManager->initializeUpload( componentId: 'test-uploader', fileName: 'test-file.txt', totalSize: Byte::fromBytes(1024), chunkSize: Byte::fromBytes(512), userId: null // No SSE ); expect($broadcastCalled)->toBeFalse(); });