- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
473 lines
16 KiB
PHP
473 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Core\ValueObjects\Byte;
|
|
use App\Framework\Filesystem\InMemoryStorage;
|
|
use App\Framework\Http\HttpRequest;
|
|
use App\Framework\Http\Responses\JsonResponse;
|
|
use App\Framework\LiveComponents\Controllers\ChunkedUploadController;
|
|
use App\Framework\LiveComponents\Services\ChunkAssembler;
|
|
use App\Framework\LiveComponents\Services\ChunkedUploadManager;
|
|
use App\Framework\LiveComponents\Services\IntegrityValidator;
|
|
use App\Framework\LiveComponents\Services\UploadProgressTracker;
|
|
use App\Framework\LiveComponents\Services\UploadSessionIdGenerator;
|
|
use App\Framework\LiveComponents\Services\UploadSessionStore;
|
|
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
|
|
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
|
|
use App\Framework\Router\Result\Status;
|
|
|
|
beforeEach(function () {
|
|
// Setup dependencies
|
|
$this->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();
|
|
});
|