Files
michaelschiemer/tests/Unit/Framework/LiveComponents/ValueObjects/UploadSessionTest.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

455 lines
17 KiB
PHP

<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
use App\Framework\LiveComponents\ValueObjects\ChunkMetadata;
use App\Framework\LiveComponents\ValueObjects\QuarantineStatus;
use App\Framework\LiveComponents\ValueObjects\UploadSession;
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
describe('UploadSession Value Object', function () {
it('creates new upload session', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$totalSize = Byte::fromMegabytes(10);
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'file-uploader-1',
fileName: 'test.pdf',
totalSize: $totalSize,
totalChunks: 10
);
expect($session->sessionId)->toBe($sessionId);
expect($session->componentId)->toBe('file-uploader-1');
expect($session->fileName)->toBe('test.pdf');
expect($session->totalSize)->toBe($totalSize);
expect($session->totalChunks)->toBe(10);
expect($session->chunks)->toBe([]);
expect($session->expectedFileHash)->toBeNull();
expect($session->quarantineStatus)->toBe(QuarantineStatus::PENDING);
});
it('sets default timestamps on creation', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$beforeCreate = new DateTimeImmutable();
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'file-uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
);
$afterCreate = new DateTimeImmutable();
expect($session->createdAt)->toBeInstanceOf(DateTimeImmutable::class);
expect($session->createdAt >= $beforeCreate)->toBeTrue();
expect($session->createdAt <= $afterCreate)->toBeTrue();
expect($session->completedAt)->toBeNull();
});
it('sets default expiry to 24 hours', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'file-uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
);
$expectedExpiry = $session->createdAt->modify('+24 hours');
$actualExpiry = $session->expiresAt;
expect($actualExpiry->getTimestamp())->toBe($expectedExpiry->getTimestamp());
});
it('accepts optional expected file hash', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$expectedHash = ChunkHash::fromData('expected final file content');
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'file-uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10,
expectedFileHash: $expectedHash
);
expect($session->expectedFileHash)->toBe($expectedHash);
});
it('rejects empty component id', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
expect(fn() => UploadSession::create(
sessionId: $sessionId,
componentId: '',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
))->toThrow(InvalidArgumentException::class);
});
it('rejects empty file name', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
expect(fn() => UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: '',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
))->toThrow(InvalidArgumentException::class);
});
it('rejects total chunks less than 1', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
expect(fn() => UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 0
))->toThrow(InvalidArgumentException::class);
});
it('adds chunk to session', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
);
$chunk = ChunkMetadata::create(
index: 0,
size: Byte::fromMegabytes(1),
hash: ChunkHash::fromData('chunk 0 data')
)->withUploaded();
$updatedSession = $session->withChunk($chunk);
expect($updatedSession->chunks)->toHaveCount(1);
expect($updatedSession->chunks[0])->toBe($chunk);
expect($session->chunks)->toHaveCount(0); // Original unchanged
});
it('sorts chunks by index when adding', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
);
// Add chunks out of order
$chunk2 = ChunkMetadata::create(2, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 2'));
$chunk0 = ChunkMetadata::create(0, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 0'));
$chunk1 = ChunkMetadata::create(1, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 1'));
$session = $session->withChunk($chunk2);
$session = $session->withChunk($chunk0);
$session = $session->withChunk($chunk1);
expect($session->chunks[0]->index)->toBe(0);
expect($session->chunks[1]->index)->toBe(1);
expect($session->chunks[2]->index)->toBe(2);
});
it('replaces existing chunk at same index', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
);
$chunk1 = ChunkMetadata::create(0, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 0 v1'));
$chunk2 = ChunkMetadata::create(0, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 0 v2'));
$session = $session->withChunk($chunk1);
expect($session->chunks)->toHaveCount(1);
$session = $session->withChunk($chunk2);
expect($session->chunks)->toHaveCount(1);
expect($session->chunks[0])->toBe($chunk2);
});
it('calculates uploaded chunks correctly', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 3
);
$uploaded1 = ChunkMetadata::create(0, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 0'))->withUploaded();
$pending = ChunkMetadata::create(1, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 1'));
$uploaded2 = ChunkMetadata::create(2, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 2'))->withUploaded();
$session = $session->withChunk($uploaded1);
$session = $session->withChunk($pending);
$session = $session->withChunk($uploaded2);
$uploadedChunks = $session->getUploadedChunks();
expect($uploadedChunks)->toHaveCount(2);
expect($uploadedChunks[0]->index)->toBe(0);
expect($uploadedChunks[1]->index)->toBe(2);
});
it('calculates uploaded bytes correctly', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 3
);
$chunk1 = ChunkMetadata::create(0, Byte::fromMegabytes(2), ChunkHash::fromData('chunk 0'))->withUploaded();
$chunk2 = ChunkMetadata::create(1, Byte::fromMegabytes(3), ChunkHash::fromData('chunk 1'))->withUploaded();
$session = $session->withChunk($chunk1);
$session = $session->withChunk($chunk2);
$uploadedBytes = $session->getUploadedBytes();
expect($uploadedBytes->toMegabytes())->toBe(5.0); // 2MB + 3MB
});
it('calculates progress percentage correctly', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 4
);
// Upload 2.5MB of 10MB = 25%
$chunk1 = ChunkMetadata::create(0, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 0'))->withUploaded();
$chunk2 = ChunkMetadata::create(1, Byte::fromBytes(1572864), ChunkHash::fromData('chunk 1'))->withUploaded(); // 1.5MB
$session = $session->withChunk($chunk1);
$session = $session->withChunk($chunk2);
expect($session->getProgress())->toBe(25.0);
});
it('returns zero progress for empty total size', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'empty.txt',
totalSize: Byte::fromBytes(0),
totalChunks: 1
);
expect($session->getProgress())->toBe(0.0);
});
it('detects completed upload', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(3),
totalChunks: 3
);
expect($session->isComplete())->toBeFalse();
$chunk0 = ChunkMetadata::create(0, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 0'))->withUploaded();
$chunk1 = ChunkMetadata::create(1, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 1'))->withUploaded();
$chunk2 = ChunkMetadata::create(2, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 2'))->withUploaded();
$session = $session->withChunk($chunk0);
$session = $session->withChunk($chunk1);
expect($session->isComplete())->toBeFalse();
$session = $session->withChunk($chunk2);
expect($session->isComplete())->toBeTrue();
});
it('detects expired session', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$pastExpiry = (new DateTimeImmutable())->modify('-1 hour');
$session = new UploadSession(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10,
expiresAt: $pastExpiry
);
expect($session->isExpired())->toBeTrue();
});
it('detects non-expired session', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$futureExpiry = (new DateTimeImmutable())->modify('+1 hour');
$session = new UploadSession(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10,
expiresAt: $futureExpiry
);
expect($session->isExpired())->toBeFalse();
});
it('identifies missing chunk indices', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 5
);
// Upload chunks 0, 2, 4 (missing 1 and 3)
$chunk0 = ChunkMetadata::create(0, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 0'))->withUploaded();
$chunk2 = ChunkMetadata::create(2, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 2'))->withUploaded();
$chunk4 = ChunkMetadata::create(4, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 4'))->withUploaded();
$session = $session->withChunk($chunk0);
$session = $session->withChunk($chunk2);
$session = $session->withChunk($chunk4);
$missing = $session->getMissingChunkIndices();
expect($missing)->toBe([1, 3]);
});
it('returns empty array when all chunks uploaded', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(2),
totalChunks: 2
);
$chunk0 = ChunkMetadata::create(0, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 0'))->withUploaded();
$chunk1 = ChunkMetadata::create(1, Byte::fromMegabytes(1), ChunkHash::fromData('chunk 1'))->withUploaded();
$session = $session->withChunk($chunk0);
$session = $session->withChunk($chunk1);
expect($session->getMissingChunkIndices())->toBe([]);
});
it('updates quarantine status with valid transition', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
);
expect($session->quarantineStatus)->toBe(QuarantineStatus::PENDING);
$scanning = $session->withQuarantineStatus(QuarantineStatus::SCANNING);
expect($scanning->quarantineStatus)->toBe(QuarantineStatus::SCANNING);
$approved = $scanning->withQuarantineStatus(QuarantineStatus::APPROVED);
expect($approved->quarantineStatus)->toBe(QuarantineStatus::APPROVED);
});
it('rejects invalid quarantine status transition', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
);
// Cannot transition directly from PENDING to APPROVED
expect(fn() => $session->withQuarantineStatus(QuarantineStatus::APPROVED))
->toThrow(InvalidArgumentException::class);
});
it('marks session as completed', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 10
);
expect($session->completedAt)->toBeNull();
$beforeComplete = new DateTimeImmutable();
$completed = $session->withCompleted();
$afterComplete = new DateTimeImmutable();
expect($completed->completedAt)->toBeInstanceOf(DateTimeImmutable::class);
expect($completed->completedAt >= $beforeComplete)->toBeTrue();
expect($completed->completedAt <= $afterComplete)->toBeTrue();
});
it('converts to array with all fields', function () {
$sessionId = UploadSessionId::fromString(str_repeat('a', 32));
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'uploader-1',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(10),
totalChunks: 5
);
$chunk0 = ChunkMetadata::create(0, Byte::fromMegabytes(2), ChunkHash::fromData('chunk 0'))->withUploaded();
$session = $session->withChunk($chunk0);
$array = $session->toArray();
expect($array['session_id'])->toBe($sessionId->toString());
expect($array['component_id'])->toBe('uploader-1');
expect($array['file_name'])->toBe('test.pdf');
expect($array['total_size'])->toBe(10485760); // 10MB in bytes
expect($array['total_size_human'])->toBeString();
expect($array['total_chunks'])->toBe(5);
expect($array['uploaded_chunks'])->toBe(1);
expect($array['uploaded_bytes'])->toBe(2097152); // 2MB in bytes
expect($array['progress'])->toBe(20.0); // 2MB / 10MB
expect($array['is_complete'])->toBeFalse();
expect($array['quarantine_status'])->toBe('pending');
expect($array)->toHaveKey('created_at');
expect($array['completed_at'])->toBeNull();
expect($array)->toHaveKey('expires_at');
expect($array['is_expired'])->toBeFalse();
expect($array['missing_chunks'])->toBe([1, 2, 3, 4]);
});
});