Files
michaelschiemer/tests/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

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