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