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 {} public function getProgress($sessionId): ?array { return null; } }; $this->uploadManager = new ChunkedUploadManager( $this->sessionIdGenerator, $this->sessionStore, $this->integrityValidator, $this->chunkAssembler, $this->fileStorage, $this->progressTracker, '/tmp/test-uploads' ); $this->controller = new ChunkedUploadController( $this->uploadManager, $this->progressTracker ); }); test('initializes upload session successfully', function () { // Arrange $request = Mockery::mock(HttpRequest::class); $request->parsedBody = (object) [ 'componentId' => 'test-uploader', 'fileName' => 'test-file.pdf', 'totalSize' => 1024 * 1024, // 1MB 'chunkSize' => 512 * 1024, // 512KB ]; $request->shouldReceive('parsedBody->toArray') ->andReturn([ 'componentId' => 'test-uploader', 'fileName' => 'test-file.pdf', 'totalSize' => 1024 * 1024, 'chunkSize' => 512 * 1024, ]); // Act $response = $this->controller->initialize($request); // Assert expect($response)->toBeInstanceOf(JsonResponse::class); expect($response->status)->toBe(Status::OK); $data = $response->data; expect($data['success'])->toBeTrue(); expect($data['session_id'])->toBeString(); expect($data['total_chunks'])->toBe(2); // 1MB / 512KB = 2 chunks expect($data['expires_at'])->toBeString(); }); test('returns error when required fields are missing', function () { // Arrange $request = Mockery::mock(HttpRequest::class); $request->parsedBody = (object) [ 'componentId' => 'test-uploader', // Missing fileName, totalSize, chunkSize ]; $request->shouldReceive('parsedBody->toArray') ->andReturn(['componentId' => 'test-uploader']); // Act $response = $this->controller->initialize($request); // Assert expect($response->status)->toBe(Status::BAD_REQUEST); expect($response->data['success'])->toBeFalse(); expect($response->data['error'])->toContain('Missing required fields'); }); test('uploads chunk successfully', function () { // Arrange - Initialize session first $initRequest = Mockery::mock(HttpRequest::class); $initRequest->shouldReceive('parsedBody->toArray') ->andReturn([ 'componentId' => 'test-uploader', 'fileName' => 'test-chunk.txt', 'totalSize' => 1024, 'chunkSize' => 512, ]); $initResponse = $this->controller->initialize($initRequest); $sessionId = $initResponse->data['session_id']; // Create temp file for chunk upload $chunkData = str_repeat('A', 512); $chunkHash = ChunkHash::fromData($chunkData); $tempFile = tempnam(sys_get_temp_dir(), 'chunk_'); file_put_contents($tempFile, $chunkData); // Upload chunk request $uploadRequest = Mockery::mock(HttpRequest::class); $uploadRequest->parsedBody = (object) []; $uploadRequest->shouldReceive('parsedBody->get') ->with('sessionId') ->andReturn($sessionId); $uploadRequest->shouldReceive('parsedBody->get') ->with('chunkIndex') ->andReturn(0); $uploadRequest->shouldReceive('parsedBody->get') ->with('chunkHash') ->andReturn($chunkHash->toString()); $uploadRequest->uploadedFiles = [ 'chunk' => [ 'tmp_name' => $tempFile, 'size' => 512, ], ]; // Act $response = $this->controller->uploadChunk($uploadRequest); // Assert expect($response)->toBeInstanceOf(JsonResponse::class); expect($response->status)->toBe(Status::OK); expect($response->data['success'])->toBeTrue(); expect($response->data['progress'])->toBe(50.0); // 1 of 2 chunks expect($response->data['uploaded_chunks'])->toBe(1); expect($response->data['total_chunks'])->toBe(2); // Cleanup @unlink($tempFile); }); test('rejects chunk upload with invalid hash', function () { // Arrange - Initialize session $initRequest = Mockery::mock(HttpRequest::class); $initRequest->shouldReceive('parsedBody->toArray') ->andReturn([ 'componentId' => 'test-uploader', 'fileName' => 'test.txt', 'totalSize' => 1024, 'chunkSize' => 512, ]); $initResponse = $this->controller->initialize($initRequest); $sessionId = $initResponse->data['session_id']; // Create chunk with wrong hash $chunkData = str_repeat('B', 512); $wrongHash = ChunkHash::fromData('different data'); $tempFile = tempnam(sys_get_temp_dir(), 'chunk_'); file_put_contents($tempFile, $chunkData); // Upload request $uploadRequest = Mockery::mock(HttpRequest::class); $uploadRequest->parsedBody = (object) []; $uploadRequest->shouldReceive('parsedBody->get') ->with('sessionId') ->andReturn($sessionId); $uploadRequest->shouldReceive('parsedBody->get') ->with('chunkIndex') ->andReturn(0); $uploadRequest->shouldReceive('parsedBody->get') ->with('chunkHash') ->andReturn($wrongHash->toString()); $uploadRequest->uploadedFiles = [ 'chunk' => [ 'tmp_name' => $tempFile, 'size' => 512, ], ]; // Act $response = $this->controller->uploadChunk($uploadRequest); // Assert expect($response->status)->toBe(Status::BAD_REQUEST); expect($response->data['success'])->toBeFalse(); expect($response->data['error'])->toContain('hash mismatch'); // Cleanup @unlink($tempFile); }); test('returns error when chunk file is missing', function () { // Arrange $request = Mockery::mock(HttpRequest::class); $request->parsedBody = (object) []; $request->shouldReceive('parsedBody->get') ->with('sessionId') ->andReturn('test-session-id'); $request->shouldReceive('parsedBody->get') ->with('chunkIndex') ->andReturn(0); $request->shouldReceive('parsedBody->get') ->with('chunkHash') ->andReturn('somehash'); $request->uploadedFiles = []; // No chunk file uploaded // Act $response = $this->controller->uploadChunk($request); // Assert expect($response->status)->toBe(Status::BAD_REQUEST); expect($response->data['success'])->toBeFalse(); expect($response->data['error'])->toBe('No chunk file uploaded'); }); test('completes upload successfully', function () { // Arrange - Initialize and upload all chunks $initRequest = Mockery::mock(HttpRequest::class); $initRequest->shouldReceive('parsedBody->toArray') ->andReturn([ 'componentId' => 'test-uploader', 'fileName' => 'complete-test.txt', 'totalSize' => 1024, 'chunkSize' => 512, ]); $initResponse = $this->controller->initialize($initRequest); $sessionId = $initResponse->data['session_id']; // Upload chunk 1 $chunk1Data = str_repeat('A', 512); $chunk1Hash = ChunkHash::fromData($chunk1Data); $tempFile1 = tempnam(sys_get_temp_dir(), 'chunk_'); file_put_contents($tempFile1, $chunk1Data); $uploadRequest1 = Mockery::mock(HttpRequest::class); $uploadRequest1->parsedBody = (object) []; $uploadRequest1->shouldReceive('parsedBody->get') ->with('sessionId') ->andReturn($sessionId); $uploadRequest1->shouldReceive('parsedBody->get') ->with('chunkIndex') ->andReturn(0); $uploadRequest1->shouldReceive('parsedBody->get') ->with('chunkHash') ->andReturn($chunk1Hash->toString()); $uploadRequest1->uploadedFiles = ['chunk' => ['tmp_name' => $tempFile1, 'size' => 512]]; $this->controller->uploadChunk($uploadRequest1); // Upload chunk 2 $chunk2Data = str_repeat('B', 512); $chunk2Hash = ChunkHash::fromData($chunk2Data); $tempFile2 = tempnam(sys_get_temp_dir(), 'chunk_'); file_put_contents($tempFile2, $chunk2Data); $uploadRequest2 = Mockery::mock(HttpRequest::class); $uploadRequest2->parsedBody = (object) []; $uploadRequest2->shouldReceive('parsedBody->get') ->with('sessionId') ->andReturn($sessionId); $uploadRequest2->shouldReceive('parsedBody->get') ->with('chunkIndex') ->andReturn(1); $uploadRequest2->shouldReceive('parsedBody->get') ->with('chunkHash') ->andReturn($chunk2Hash->toString()); $uploadRequest2->uploadedFiles = ['chunk' => ['tmp_name' => $tempFile2, 'size' => 512]]; $this->controller->uploadChunk($uploadRequest2); // Complete upload $completeRequest = Mockery::mock(HttpRequest::class); $completeRequest->shouldReceive('parsedBody->toArray') ->andReturn([ 'sessionId' => $sessionId, 'targetPath' => '/tmp/completed-file.txt', ]); // Act $response = $this->controller->complete($completeRequest); // Assert expect($response)->toBeInstanceOf(JsonResponse::class); expect($response->status)->toBe(Status::OK); expect($response->data['success'])->toBeTrue(); expect($response->data['file_path'])->toBe('/tmp/completed-file.txt'); expect($response->data['completed_at'])->toBeString(); // Cleanup @unlink($tempFile1); @unlink($tempFile2); }); test('returns error when completing with missing chunks', function () { // Arrange - Initialize but don't upload all chunks $initRequest = Mockery::mock(HttpRequest::class); $initRequest->shouldReceive('parsedBody->toArray') ->andReturn([ 'componentId' => 'test-uploader', 'fileName' => 'incomplete.txt', 'totalSize' => 1024, 'chunkSize' => 512, ]); $initResponse = $this->controller->initialize($initRequest); $sessionId = $initResponse->data['session_id']; // Upload only first chunk (missing second chunk) $chunkData = str_repeat('A', 512); $chunkHash = ChunkHash::fromData($chunkData); $tempFile = tempnam(sys_get_temp_dir(), 'chunk_'); file_put_contents($tempFile, $chunkData); $uploadRequest = Mockery::mock(HttpRequest::class); $uploadRequest->parsedBody = (object) []; $uploadRequest->shouldReceive('parsedBody->get') ->with('sessionId') ->andReturn($sessionId); $uploadRequest->shouldReceive('parsedBody->get') ->with('chunkIndex') ->andReturn(0); $uploadRequest->shouldReceive('parsedBody->get') ->with('chunkHash') ->andReturn($chunkHash->toString()); $uploadRequest->uploadedFiles = ['chunk' => ['tmp_name' => $tempFile, 'size' => 512]]; $this->controller->uploadChunk($uploadRequest); // Try to complete with missing chunks $completeRequest = Mockery::mock(HttpRequest::class); $completeRequest->shouldReceive('parsedBody->toArray') ->andReturn([ 'sessionId' => $sessionId, 'targetPath' => '/tmp/incomplete-file.txt', ]); // Act $response = $this->controller->complete($completeRequest); // Assert expect($response->status)->toBe(Status::BAD_REQUEST); expect($response->data['success'])->toBeFalse(); expect($response->data['error'])->toContain('Upload incomplete'); // Cleanup @unlink($tempFile); }); test('aborts upload successfully', function () { // Arrange - Initialize session $initRequest = Mockery::mock(HttpRequest::class); $initRequest->shouldReceive('parsedBody->toArray') ->andReturn([ 'componentId' => 'test-uploader', 'fileName' => 'abort-test.txt', 'totalSize' => 1024, 'chunkSize' => 512, ]); $initResponse = $this->controller->initialize($initRequest); $sessionId = $initResponse->data['session_id']; // Abort request $abortRequest = Mockery::mock(HttpRequest::class); $abortRequest->shouldReceive('parsedBody->toArray') ->andReturn([ 'sessionId' => $sessionId, 'reason' => 'User cancelled upload', ]); // Act $response = $this->controller->abort($abortRequest); // Assert expect($response)->toBeInstanceOf(JsonResponse::class); expect($response->status)->toBe(Status::OK); expect($response->data['success'])->toBeTrue(); }); test('returns error when aborting with missing sessionId', function () { // Arrange $request = Mockery::mock(HttpRequest::class); $request->shouldReceive('parsedBody->toArray') ->andReturn([]); // Missing sessionId // Act $response = $this->controller->abort($request); // Assert expect($response->status)->toBe(Status::BAD_REQUEST); expect($response->data['success'])->toBeFalse(); expect($response->data['error'])->toBe('Missing required field: sessionId'); }); test('returns status for active upload session', function () { // Note: This test requires a mock UploadProgressTracker that returns actual data // For now, we'll test the controller handles the response correctly $sessionId = UploadSessionId::generate()->toString(); // Create a progress tracker that returns data $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 {} public function getProgress($sessionId): ?array { return [ 'progress' => 50.0, 'uploaded_chunks' => 1, 'total_chunks' => 2, 'uploaded_bytes' => 512, 'total_bytes' => 1024, 'phase' => 'uploading', 'quarantine_status' => 'pending', ]; } }; $controller = new ChunkedUploadController( $this->uploadManager, $progressTracker ); // Act $response = $controller->status($sessionId); // Assert expect($response)->toBeInstanceOf(JsonResponse::class); expect($response->status)->toBe(Status::OK); expect($response->data['success'])->toBeTrue(); expect($response->data['progress'])->toBe(50.0); expect($response->data['uploaded_chunks'])->toBe(1); expect($response->data['total_chunks'])->toBe(2); }); test('returns not found when session does not exist', function () { $nonExistentSessionId = UploadSessionId::generate()->toString(); // Act $response = $this->controller->status($nonExistentSessionId); // Assert expect($response->status)->toBe(Status::NOT_FOUND); expect($response->data['success'])->toBeFalse(); expect($response->data['error'])->toBe('Upload session not found'); }); afterEach(function () { Mockery::close(); });