- 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.
455 lines
17 KiB
PHP
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]);
|
|
});
|
|
});
|