Files
michaelschiemer/tests/Integration/Framework/LiveComponents/ChunkedUploadManagerTest.php
Michael Schiemer fc3d7e6357 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.
2025-10-25 19:18:37 +02:00

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);
});
});