- 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.
474 lines
17 KiB
PHP
474 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Core\ValueObjects\Byte;
|
|
use App\Framework\Filesystem\FileStorage;
|
|
use App\Framework\LiveComponents\Services\ChunkedUploadManager;
|
|
use App\Framework\LiveComponents\Services\ChunkAssembler;
|
|
use App\Framework\LiveComponents\Services\IntegrityValidator;
|
|
use App\Framework\LiveComponents\Services\UploadSessionIdGenerator;
|
|
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
|
|
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
|
|
use App\Framework\Random\RandomGenerator;
|
|
use Tests\Support\InMemoryUploadProgressTracker;
|
|
use Tests\Support\InMemoryUploadSessionStore;
|
|
|
|
describe('ChunkedUploadManager Integration', function () {
|
|
beforeEach(function () {
|
|
// Setup test directory
|
|
$this->testDir = sys_get_temp_dir() . '/chunked_upload_test_' . uniqid();
|
|
mkdir($this->testDir);
|
|
|
|
// Setup dependencies
|
|
$this->randomGen = new class implements RandomGenerator {
|
|
public function bytes(int $length): string
|
|
{
|
|
return random_bytes($length);
|
|
}
|
|
|
|
public function int(int $min, int $max): int
|
|
{
|
|
return random_int($min, $max);
|
|
}
|
|
|
|
public function float(): float
|
|
{
|
|
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
|
|
}
|
|
};
|
|
|
|
$this->sessionIdGenerator = new UploadSessionIdGenerator($this->randomGen);
|
|
$this->sessionStore = new InMemoryUploadSessionStore();
|
|
$this->integrityValidator = new IntegrityValidator();
|
|
$this->fileStorage = new FileStorage();
|
|
$this->chunkAssembler = new ChunkAssembler($this->fileStorage);
|
|
$this->progressTracker = new InMemoryUploadProgressTracker();
|
|
|
|
$this->uploadManager = new ChunkedUploadManager(
|
|
sessionIdGenerator: $this->sessionIdGenerator,
|
|
sessionStore: $this->sessionStore,
|
|
integrityValidator: $this->integrityValidator,
|
|
chunkAssembler: $this->chunkAssembler,
|
|
fileStorage: $this->fileStorage,
|
|
progressTracker: $this->progressTracker,
|
|
uploadBasePath: $this->testDir
|
|
);
|
|
});
|
|
|
|
afterEach(function () {
|
|
// Cleanup test directory
|
|
if (is_dir($this->testDir)) {
|
|
$files = glob($this->testDir . '/*');
|
|
foreach ($files as $file) {
|
|
if (is_file($file)) {
|
|
unlink($file);
|
|
} elseif (is_dir($file)) {
|
|
$subFiles = glob($file . '/*');
|
|
foreach ($subFiles as $subFile) {
|
|
if (is_file($subFile)) {
|
|
unlink($subFile);
|
|
}
|
|
}
|
|
rmdir($file);
|
|
}
|
|
}
|
|
rmdir($this->testDir);
|
|
}
|
|
});
|
|
|
|
it('initializes upload session', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.pdf',
|
|
totalSize: Byte::fromKilobytes(10),
|
|
chunkSize: Byte::fromKilobytes(2)
|
|
);
|
|
|
|
expect($session->componentId)->toBe('test-component');
|
|
expect($session->fileName)->toBe('test.pdf');
|
|
expect($session->totalSize->toKilobytes())->toBe(10.0);
|
|
expect($session->totalChunks)->toBe(5); // 10KB / 2KB = 5 chunks
|
|
expect($session->isComplete())->toBe(false);
|
|
|
|
// Verify session is persisted
|
|
$retrieved = $this->uploadManager->getStatus($session->sessionId);
|
|
expect($retrieved !== null)->toBe(true);
|
|
expect($retrieved->sessionId->equals($session->sessionId))->toBeTrue();
|
|
});
|
|
|
|
it('initializes with SSE broadcast', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.pdf',
|
|
totalSize: Byte::fromKilobytes(10),
|
|
chunkSize: Byte::fromKilobytes(2),
|
|
userId: 'user123'
|
|
);
|
|
|
|
$broadcasts = $this->progressTracker->getBroadcasts();
|
|
expect(count($broadcasts))->toBe(1);
|
|
expect($broadcasts[0]['type'])->toBe('initialized');
|
|
expect($broadcasts[0]['user_id'])->toBe('user123');
|
|
});
|
|
|
|
it('uploads chunks sequentially', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(300),
|
|
chunkSize: Byte::fromBytes(100)
|
|
);
|
|
|
|
$chunk1Data = str_repeat('A', 100);
|
|
$chunk1Hash = ChunkHash::fromData($chunk1Data);
|
|
|
|
$updatedSession = $this->uploadManager->uploadChunk(
|
|
sessionId: $session->sessionId,
|
|
chunkIndex: 0,
|
|
chunkData: $chunk1Data,
|
|
providedHash: $chunk1Hash
|
|
);
|
|
|
|
expect(count($updatedSession->getUploadedChunks()))->toBe(1);
|
|
expect($updatedSession->getProgress())->toBeGreaterThan(0);
|
|
expect($updatedSession->isComplete())->toBe(false);
|
|
|
|
// Upload second chunk
|
|
$chunk2Data = str_repeat('B', 100);
|
|
$chunk2Hash = ChunkHash::fromData($chunk2Data);
|
|
|
|
$updatedSession = $this->uploadManager->uploadChunk(
|
|
sessionId: $session->sessionId,
|
|
chunkIndex: 1,
|
|
chunkData: $chunk2Data,
|
|
providedHash: $chunk2Hash
|
|
);
|
|
|
|
expect(count($updatedSession->getUploadedChunks()))->toBe(2);
|
|
});
|
|
|
|
it('uploads chunks with SSE broadcasts', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(200),
|
|
chunkSize: Byte::fromBytes(100),
|
|
userId: 'user123'
|
|
);
|
|
|
|
$this->progressTracker->clear();
|
|
|
|
$chunk1Data = str_repeat('A', 100);
|
|
$chunk1Hash = ChunkHash::fromData($chunk1Data);
|
|
|
|
$this->uploadManager->uploadChunk(
|
|
sessionId: $session->sessionId,
|
|
chunkIndex: 0,
|
|
chunkData: $chunk1Data,
|
|
providedHash: $chunk1Hash,
|
|
userId: 'user123'
|
|
);
|
|
|
|
expect($this->progressTracker->getBroadcastCount('chunk_uploaded'))->toBe(1);
|
|
});
|
|
|
|
it('completes upload and assembles file', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(300),
|
|
chunkSize: Byte::fromBytes(100)
|
|
);
|
|
|
|
// Upload all chunks
|
|
for ($i = 0; $i < 3; $i++) {
|
|
$chunkData = str_repeat(chr(65 + $i), 100); // A, B, C
|
|
$chunkHash = ChunkHash::fromData($chunkData);
|
|
|
|
$this->uploadManager->uploadChunk(
|
|
sessionId: $session->sessionId,
|
|
chunkIndex: $i,
|
|
chunkData: $chunkData,
|
|
providedHash: $chunkHash
|
|
);
|
|
}
|
|
|
|
// Complete upload
|
|
$targetPath = $this->testDir . '/assembled.txt';
|
|
$completedSession = $this->uploadManager->completeUpload(
|
|
sessionId: $session->sessionId,
|
|
targetPath: $targetPath
|
|
);
|
|
|
|
expect($completedSession->isComplete())->toBe(true);
|
|
expect($completedSession->completedAt !== null)->toBe(true);
|
|
expect(file_exists($targetPath))->toBeTrue();
|
|
|
|
$content = file_get_contents($targetPath);
|
|
expect(strlen($content))->toBe(300);
|
|
expect(substr($content, 0, 100))->toBe(str_repeat('A', 100));
|
|
expect(substr($content, 100, 100))->toBe(str_repeat('B', 100));
|
|
expect(substr($content, 200, 100))->toBe(str_repeat('C', 100));
|
|
});
|
|
|
|
it('validates final file hash', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(200),
|
|
chunkSize: Byte::fromBytes(100),
|
|
expectedFileHash: ChunkHash::fromData(str_repeat('A', 100) . str_repeat('B', 100))
|
|
);
|
|
|
|
// Upload chunks
|
|
$chunk1Data = str_repeat('A', 100);
|
|
$chunk1Hash = ChunkHash::fromData($chunk1Data);
|
|
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunk1Data, $chunk1Hash);
|
|
|
|
$chunk2Data = str_repeat('B', 100);
|
|
$chunk2Hash = ChunkHash::fromData($chunk2Data);
|
|
$this->uploadManager->uploadChunk($session->sessionId, 1, $chunk2Data, $chunk2Hash);
|
|
|
|
// Complete upload - should succeed with matching hash
|
|
$targetPath = $this->testDir . '/assembled.txt';
|
|
$completedSession = $this->uploadManager->completeUpload($session->sessionId, $targetPath);
|
|
|
|
expect($completedSession->isComplete())->toBe(true);
|
|
expect(file_exists($targetPath))->toBeTrue();
|
|
});
|
|
|
|
it('rejects mismatched final file hash', function () {
|
|
$wrongHash = ChunkHash::fromData('wrong content');
|
|
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(200),
|
|
chunkSize: Byte::fromBytes(100),
|
|
expectedFileHash: $wrongHash
|
|
);
|
|
|
|
// Upload chunks
|
|
$chunk1Data = str_repeat('A', 100);
|
|
$chunk1Hash = ChunkHash::fromData($chunk1Data);
|
|
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunk1Data, $chunk1Hash);
|
|
|
|
$chunk2Data = str_repeat('B', 100);
|
|
$chunk2Hash = ChunkHash::fromData($chunk2Data);
|
|
$this->uploadManager->uploadChunk($session->sessionId, 1, $chunk2Data, $chunk2Hash);
|
|
|
|
// Complete upload - should fail with hash mismatch
|
|
$targetPath = $this->testDir . '/assembled.txt';
|
|
|
|
expect(fn() => $this->uploadManager->completeUpload($session->sessionId, $targetPath))
|
|
->toThrow(InvalidArgumentException::class);
|
|
|
|
// Verify file was cleaned up after hash mismatch
|
|
expect(file_exists($targetPath))->toBe(false);
|
|
});
|
|
|
|
it('rejects chunk with invalid hash', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(200),
|
|
chunkSize: Byte::fromBytes(100)
|
|
);
|
|
|
|
$chunkData = str_repeat('A', 100);
|
|
$wrongHash = ChunkHash::fromData('wrong data');
|
|
|
|
expect(fn() => $this->uploadManager->uploadChunk(
|
|
$session->sessionId,
|
|
0,
|
|
$chunkData,
|
|
$wrongHash
|
|
))->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
it('rejects chunk for non-existent session', function () {
|
|
$nonExistentSessionId = $this->sessionIdGenerator->generate();
|
|
$chunkData = str_repeat('A', 100);
|
|
$chunkHash = ChunkHash::fromData($chunkData);
|
|
|
|
expect(fn() => $this->uploadManager->uploadChunk(
|
|
$nonExistentSessionId,
|
|
0,
|
|
$chunkData,
|
|
$chunkHash
|
|
))->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
it('rejects chunk for expired session', function () {
|
|
$sessionId = $this->sessionIdGenerator->generate();
|
|
|
|
// Create expired session
|
|
$expiredSession = new \App\Framework\LiveComponents\ValueObjects\UploadSession(
|
|
sessionId: $sessionId,
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(200),
|
|
totalChunks: 2,
|
|
createdAt: new DateTimeImmutable('-2 hours'),
|
|
expiresAt: new DateTimeImmutable('-1 hour')
|
|
);
|
|
|
|
$this->sessionStore->save($expiredSession);
|
|
|
|
$chunkData = str_repeat('A', 100);
|
|
$chunkHash = ChunkHash::fromData($chunkData);
|
|
|
|
expect(fn() => $this->uploadManager->uploadChunk(
|
|
$sessionId,
|
|
0,
|
|
$chunkData,
|
|
$chunkHash
|
|
))->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
it('rejects invalid chunk index', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(200),
|
|
chunkSize: Byte::fromBytes(100)
|
|
);
|
|
|
|
$chunkData = str_repeat('A', 100);
|
|
$chunkHash = ChunkHash::fromData($chunkData);
|
|
|
|
// Chunk index out of range (totalChunks = 2, so valid indices are 0,1)
|
|
expect(fn() => $this->uploadManager->uploadChunk(
|
|
$session->sessionId,
|
|
5,
|
|
$chunkData,
|
|
$chunkHash
|
|
))->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
it('prevents completion with missing chunks', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(300),
|
|
chunkSize: Byte::fromBytes(100)
|
|
);
|
|
|
|
// Upload only first chunk (missing chunks 1 and 2)
|
|
$chunkData = str_repeat('A', 100);
|
|
$chunkHash = ChunkHash::fromData($chunkData);
|
|
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunkData, $chunkHash);
|
|
|
|
$targetPath = $this->testDir . '/assembled.txt';
|
|
|
|
expect(fn() => $this->uploadManager->completeUpload($session->sessionId, $targetPath))
|
|
->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
it('aborts upload and cleans up', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(300),
|
|
chunkSize: Byte::fromBytes(100)
|
|
);
|
|
|
|
// Upload one chunk
|
|
$chunkData = str_repeat('A', 100);
|
|
$chunkHash = ChunkHash::fromData($chunkData);
|
|
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunkData, $chunkHash);
|
|
|
|
// Abort upload
|
|
$this->uploadManager->abortUpload($session->sessionId);
|
|
|
|
// Verify session is deleted
|
|
expect($this->uploadManager->getStatus($session->sessionId))->toBeNull();
|
|
|
|
// Verify chunks are cleaned up
|
|
expect($this->sessionStore->exists($session->sessionId))->toBe(false);
|
|
});
|
|
|
|
it('aborts with SSE broadcast', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(200),
|
|
chunkSize: Byte::fromBytes(100),
|
|
userId: 'user123'
|
|
);
|
|
|
|
$this->progressTracker->clear();
|
|
|
|
$this->uploadManager->abortUpload($session->sessionId, userId: 'user123', reason: 'Test abort');
|
|
|
|
expect($this->progressTracker->getBroadcastCount('aborted'))->toBe(1);
|
|
|
|
$abortBroadcasts = $this->progressTracker->getBroadcastsByType('aborted');
|
|
expect($abortBroadcasts[0]['data']['reason'])->toBe('Test abort');
|
|
});
|
|
|
|
it('handles resume capability', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(300),
|
|
chunkSize: Byte::fromBytes(100)
|
|
);
|
|
|
|
// Upload chunks 0 and 2 (skip chunk 1)
|
|
$chunk0Data = str_repeat('A', 100);
|
|
$chunk0Hash = ChunkHash::fromData($chunk0Data);
|
|
$this->uploadManager->uploadChunk($session->sessionId, 0, $chunk0Data, $chunk0Hash);
|
|
|
|
$chunk2Data = str_repeat('C', 100);
|
|
$chunk2Hash = ChunkHash::fromData($chunk2Data);
|
|
$this->uploadManager->uploadChunk($session->sessionId, 2, $chunk2Data, $chunk2Hash);
|
|
|
|
// Get status to see missing chunks
|
|
$currentSession = $this->uploadManager->getStatus($session->sessionId);
|
|
$missingChunks = $currentSession->getMissingChunkIndices();
|
|
|
|
expect($missingChunks)->toBe([1]);
|
|
|
|
// Upload missing chunk
|
|
$chunk1Data = str_repeat('B', 100);
|
|
$chunk1Hash = ChunkHash::fromData($chunk1Data);
|
|
$this->uploadManager->uploadChunk($session->sessionId, 1, $chunk1Data, $chunk1Hash);
|
|
|
|
// Now should be complete
|
|
$finalSession = $this->uploadManager->getStatus($session->sessionId);
|
|
expect($finalSession->isComplete())->toBe(true);
|
|
});
|
|
|
|
it('creates session directory on initialization', function () {
|
|
$session = $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(200),
|
|
chunkSize: Byte::fromBytes(100)
|
|
);
|
|
|
|
$sessionPath = $this->testDir . '/' . $session->sessionId->toString();
|
|
expect(is_dir($sessionPath))->toBeTrue();
|
|
});
|
|
|
|
it('validates empty file size', function () {
|
|
expect(fn() => $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(0),
|
|
chunkSize: Byte::fromBytes(100)
|
|
))->toThrow(InvalidArgumentException::class);
|
|
});
|
|
|
|
it('validates empty chunk size', function () {
|
|
expect(fn() => $this->uploadManager->initializeUpload(
|
|
componentId: 'test-component',
|
|
fileName: 'test.txt',
|
|
totalSize: Byte::fromBytes(200),
|
|
chunkSize: Byte::fromBytes(0)
|
|
))->toThrow(InvalidArgumentException::class);
|
|
});
|
|
});
|