- 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.
370 lines
12 KiB
PHP
370 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Framework\LiveComponents;
|
|
|
|
use App\Framework\Core\ValueObjects\Byte;
|
|
use App\Framework\Filesystem\InMemoryStorage;
|
|
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;
|
|
|
|
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 {}
|
|
};
|
|
|
|
$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();
|
|
});
|