feat(Production): Complete production deployment infrastructure

- 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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,472 @@
<?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();
});