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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
describe('ChunkHash Value Object', function () {
it('creates hash from chunk data', function () {
$data = 'test chunk data';
$hash = ChunkHash::fromData($data);
expect($hash->toString())->toBeString();
expect(strlen($hash->toString()))->toBe(64); // SHA-256 = 64 hex characters
});
it('creates hash from string', function () {
$hashString = hash('sha256', 'test data');
$hash = ChunkHash::fromString($hashString);
expect($hash->toString())->toBe($hashString);
});
it('creates hash from file', function () {
$tempFile = tempnam(sys_get_temp_dir(), 'chunk_test_');
file_put_contents($tempFile, 'test file content');
try {
$hash = ChunkHash::fromFile($tempFile);
expect($hash->toString())->toBeString();
expect(strlen($hash->toString()))->toBe(64);
} finally {
unlink($tempFile);
}
});
it('verifies chunk data correctly', function () {
$data = 'test chunk data';
$hash = ChunkHash::fromData($data);
expect($hash->verify($data))->toBeTrue();
expect($hash->verify('different data'))->toBeFalse();
});
it('verifies file correctly', function () {
$tempFile = tempnam(sys_get_temp_dir(), 'chunk_test_');
file_put_contents($tempFile, 'test file content');
try {
$hash = ChunkHash::fromFile($tempFile);
expect($hash->verifyFile($tempFile))->toBeTrue();
// Modify file and verify again
file_put_contents($tempFile, 'modified content');
expect($hash->verifyFile($tempFile))->toBeFalse();
} finally {
unlink($tempFile);
}
});
it('compares hashes for equality', function () {
$data = 'test data';
$hash1 = ChunkHash::fromData($data);
$hash2 = ChunkHash::fromData($data);
$hash3 = ChunkHash::fromData('different data');
expect($hash1->equals($hash2))->toBeTrue();
expect($hash1->equals($hash3))->toBeFalse();
});
it('converts to string', function () {
$data = 'test data';
$hash = ChunkHash::fromData($data);
$expectedHash = hash('sha256', $data);
expect($hash->toString())->toBe($expectedHash);
expect((string) $hash)->toBe($expectedHash);
});
it('produces consistent hashes for same data', function () {
$data = 'consistent data';
$hash1 = ChunkHash::fromData($data);
$hash2 = ChunkHash::fromData($data);
expect($hash1->toString())->toBe($hash2->toString());
});
it('produces different hashes for different data', function () {
$hash1 = ChunkHash::fromData('data 1');
$hash2 = ChunkHash::fromData('data 2');
$hash1String = $hash1->toString();
$hash2String = $hash2->toString();
expect($hash1String === $hash2String)->toBeFalse();
});
it('exposes underlying Hash value object', function () {
$hash = ChunkHash::fromData('test');
expect($hash->getHash())->toBeInstanceOf(\App\Framework\Core\ValueObjects\Hash::class);
});
});

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
use App\Framework\LiveComponents\ValueObjects\ChunkMetadata;
describe('ChunkMetadata Value Object', function () {
it('creates chunk metadata', function () {
$hash = ChunkHash::fromData('test chunk');
$size = Byte::fromKilobytes(100);
$metadata = ChunkMetadata::create(
index: 0,
size: $size,
hash: $hash
);
expect($metadata->index)->toBe(0);
expect($metadata->size)->toBe($size);
expect($metadata->hash)->toBe($hash);
expect($metadata->uploaded)->toBeFalse();
});
it('rejects negative chunk index', function () {
$hash = ChunkHash::fromData('test');
$size = Byte::fromKilobytes(100);
expect(fn() => ChunkMetadata::create(-1, $size, $hash))
->toThrow(InvalidArgumentException::class);
});
it('rejects zero chunk size', function () {
$hash = ChunkHash::fromData('test');
$size = Byte::fromBytes(0);
expect(fn() => ChunkMetadata::create(0, $size, $hash))
->toThrow(InvalidArgumentException::class);
});
it('marks chunk as uploaded', function () {
$hash = ChunkHash::fromData('test');
$size = Byte::fromKilobytes(100);
$metadata = ChunkMetadata::create(0, $size, $hash);
expect($metadata->isUploaded())->toBeFalse();
$uploaded = $metadata->withUploaded();
expect($uploaded->isUploaded())->toBeTrue();
expect($uploaded->index)->toBe($metadata->index);
expect($uploaded->size)->toBe($metadata->size);
expect($uploaded->hash)->toBe($metadata->hash);
});
it('preserves immutability when marking uploaded', function () {
$hash = ChunkHash::fromData('test');
$size = Byte::fromKilobytes(100);
$original = ChunkMetadata::create(0, $size, $hash);
$uploaded = $original->withUploaded();
expect($original->isUploaded())->toBeFalse();
expect($uploaded->isUploaded())->toBeTrue();
});
it('converts to array correctly', function () {
$hash = ChunkHash::fromData('test chunk data');
$size = Byte::fromKilobytes(100);
$metadata = ChunkMetadata::create(5, $size, $hash);
$array = $metadata->toArray();
expect($array['index'])->toBe(5);
expect($array['size'])->toBe(102400); // 100KB in bytes
expect($array['size_human'])->toBeString();
expect($array['hash'])->toBe($hash->toString());
expect($array['uploaded'])->toBeFalse();
});
it('includes uploaded status in array', function () {
$hash = ChunkHash::fromData('test');
$size = Byte::fromKilobytes(100);
$metadata = ChunkMetadata::create(0, $size, $hash)->withUploaded();
$array = $metadata->toArray();
expect($array['uploaded'])->toBeTrue();
});
it('handles different chunk sizes', function () {
$hash = ChunkHash::fromData('test');
$small = ChunkMetadata::create(0, Byte::fromBytes(1), $hash);
$medium = ChunkMetadata::create(1, Byte::fromMegabytes(5), $hash);
$large = ChunkMetadata::create(2, Byte::fromMegabytes(100), $hash);
expect($small->size->toBytes())->toBe(1);
expect($medium->size->toBytes())->toBe(5242880); // 5MB
expect($large->size->toBytes())->toBe(104857600); // 100MB
});
it('maintains chunk index ordering', function () {
$hash = ChunkHash::fromData('test');
$size = Byte::fromKilobytes(100);
$chunks = [
ChunkMetadata::create(0, $size, $hash),
ChunkMetadata::create(5, $size, $hash),
ChunkMetadata::create(10, $size, $hash),
];
expect($chunks[0]->index)->toBe(0);
expect($chunks[1]->index)->toBe(5);
expect($chunks[2]->index)->toBe(10);
});
});

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\ValueObjects\QuarantineStatus;
describe('QuarantineStatus Enum', function () {
it('has all expected status values', function () {
$statuses = QuarantineStatus::cases();
expect($statuses)->toHaveCount(5);
expect(QuarantineStatus::PENDING)->toBeInstanceOf(QuarantineStatus::class);
expect(QuarantineStatus::SCANNING)->toBeInstanceOf(QuarantineStatus::class);
expect(QuarantineStatus::APPROVED)->toBeInstanceOf(QuarantineStatus::class);
expect(QuarantineStatus::REJECTED)->toBeInstanceOf(QuarantineStatus::class);
expect(QuarantineStatus::EXPIRED)->toBeInstanceOf(QuarantineStatus::class);
});
it('identifies pending status', function () {
expect(QuarantineStatus::PENDING->isPending())->toBeTrue();
expect(QuarantineStatus::SCANNING->isPending())->toBeFalse();
expect(QuarantineStatus::APPROVED->isPending())->toBeFalse();
});
it('identifies scanning status', function () {
expect(QuarantineStatus::SCANNING->isScanning())->toBeTrue();
expect(QuarantineStatus::PENDING->isScanning())->toBeFalse();
expect(QuarantineStatus::APPROVED->isScanning())->toBeFalse();
});
it('identifies approved status', function () {
expect(QuarantineStatus::APPROVED->isApproved())->toBeTrue();
expect(QuarantineStatus::PENDING->isApproved())->toBeFalse();
expect(QuarantineStatus::REJECTED->isApproved())->toBeFalse();
});
it('identifies rejected status', function () {
expect(QuarantineStatus::REJECTED->isRejected())->toBeTrue();
expect(QuarantineStatus::PENDING->isRejected())->toBeFalse();
expect(QuarantineStatus::APPROVED->isRejected())->toBeFalse();
});
it('identifies expired status', function () {
expect(QuarantineStatus::EXPIRED->isExpired())->toBeTrue();
expect(QuarantineStatus::PENDING->isExpired())->toBeFalse();
expect(QuarantineStatus::SCANNING->isExpired())->toBeFalse();
});
it('identifies final statuses', function () {
expect(QuarantineStatus::APPROVED->isFinal())->toBeTrue();
expect(QuarantineStatus::REJECTED->isFinal())->toBeTrue();
expect(QuarantineStatus::EXPIRED->isFinal())->toBeTrue();
expect(QuarantineStatus::PENDING->isFinal())->toBeFalse();
expect(QuarantineStatus::SCANNING->isFinal())->toBeFalse();
});
it('allows transition from PENDING to SCANNING', function () {
$status = QuarantineStatus::PENDING;
expect($status->canTransitionTo(QuarantineStatus::SCANNING))->toBeTrue();
});
it('allows transition from PENDING to EXPIRED', function () {
$status = QuarantineStatus::PENDING;
expect($status->canTransitionTo(QuarantineStatus::EXPIRED))->toBeTrue();
});
it('rejects transition from PENDING to APPROVED', function () {
$status = QuarantineStatus::PENDING;
expect($status->canTransitionTo(QuarantineStatus::APPROVED))->toBeFalse();
});
it('rejects transition from PENDING to REJECTED', function () {
$status = QuarantineStatus::PENDING;
expect($status->canTransitionTo(QuarantineStatus::REJECTED))->toBeFalse();
});
it('allows transition from SCANNING to APPROVED', function () {
$status = QuarantineStatus::SCANNING;
expect($status->canTransitionTo(QuarantineStatus::APPROVED))->toBeTrue();
});
it('allows transition from SCANNING to REJECTED', function () {
$status = QuarantineStatus::SCANNING;
expect($status->canTransitionTo(QuarantineStatus::REJECTED))->toBeTrue();
});
it('allows transition from SCANNING to EXPIRED', function () {
$status = QuarantineStatus::SCANNING;
expect($status->canTransitionTo(QuarantineStatus::EXPIRED))->toBeTrue();
});
it('rejects transition from SCANNING to PENDING', function () {
$status = QuarantineStatus::SCANNING;
expect($status->canTransitionTo(QuarantineStatus::PENDING))->toBeFalse();
});
it('rejects all transitions from APPROVED', function () {
$status = QuarantineStatus::APPROVED;
expect($status->canTransitionTo(QuarantineStatus::PENDING))->toBeFalse();
expect($status->canTransitionTo(QuarantineStatus::SCANNING))->toBeFalse();
expect($status->canTransitionTo(QuarantineStatus::REJECTED))->toBeFalse();
expect($status->canTransitionTo(QuarantineStatus::EXPIRED))->toBeFalse();
});
it('rejects all transitions from REJECTED', function () {
$status = QuarantineStatus::REJECTED;
expect($status->canTransitionTo(QuarantineStatus::PENDING))->toBeFalse();
expect($status->canTransitionTo(QuarantineStatus::SCANNING))->toBeFalse();
expect($status->canTransitionTo(QuarantineStatus::APPROVED))->toBeFalse();
expect($status->canTransitionTo(QuarantineStatus::EXPIRED))->toBeFalse();
});
it('rejects all transitions from EXPIRED', function () {
$status = QuarantineStatus::EXPIRED;
expect($status->canTransitionTo(QuarantineStatus::PENDING))->toBeFalse();
expect($status->canTransitionTo(QuarantineStatus::SCANNING))->toBeFalse();
expect($status->canTransitionTo(QuarantineStatus::APPROVED))->toBeFalse();
expect($status->canTransitionTo(QuarantineStatus::REJECTED))->toBeFalse();
});
it('has string values matching case names', function () {
expect(QuarantineStatus::PENDING->value)->toBe('pending');
expect(QuarantineStatus::SCANNING->value)->toBe('scanning');
expect(QuarantineStatus::APPROVED->value)->toBe('approved');
expect(QuarantineStatus::REJECTED->value)->toBe('rejected');
expect(QuarantineStatus::EXPIRED->value)->toBe('expired');
});
it('follows expected state machine flow', function () {
// Happy path: PENDING → SCANNING → APPROVED
expect(QuarantineStatus::PENDING->canTransitionTo(QuarantineStatus::SCANNING))->toBeTrue();
expect(QuarantineStatus::SCANNING->canTransitionTo(QuarantineStatus::APPROVED))->toBeTrue();
// Rejection path: PENDING → SCANNING → REJECTED
expect(QuarantineStatus::PENDING->canTransitionTo(QuarantineStatus::SCANNING))->toBeTrue();
expect(QuarantineStatus::SCANNING->canTransitionTo(QuarantineStatus::REJECTED))->toBeTrue();
// Timeout path: PENDING → EXPIRED
expect(QuarantineStatus::PENDING->canTransitionTo(QuarantineStatus::EXPIRED))->toBeTrue();
// Scan timeout: SCANNING → EXPIRED
expect(QuarantineStatus::SCANNING->canTransitionTo(QuarantineStatus::EXPIRED))->toBeTrue();
});
});

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
describe('UploadSessionId Value Object', function () {
it('creates session id from valid string', function () {
$value = str_repeat('a', 32); // 32 characters
$sessionId = UploadSessionId::fromString($value);
expect($sessionId->value)->toBe($value);
expect($sessionId->toString())->toBe($value);
});
it('rejects session id shorter than 32 characters', function () {
$value = str_repeat('a', 31); // 31 characters - too short
expect(fn() => UploadSessionId::fromString($value))
->toThrow(InvalidArgumentException::class);
});
it('accepts session id longer than 32 characters', function () {
$value = str_repeat('a', 64); // 64 characters - valid
$sessionId = UploadSessionId::fromString($value);
expect($sessionId->value)->toBe($value);
});
it('rejects non-alphanumeric characters', function () {
$value = str_repeat('a', 31) . '@'; // 32 chars with special char
expect(fn() => UploadSessionId::fromString($value))
->toThrow(InvalidArgumentException::class);
});
it('accepts uppercase and lowercase alphanumeric', function () {
$value = 'abcdefghijklmnopqrstuvwxyz012345'; // 32 chars
$sessionId = UploadSessionId::fromString($value);
expect($sessionId->value)->toBe($value);
});
it('accepts mixed case alphanumeric', function () {
$value = 'AbCdEfGh12345678IjKlMnOp90123456'; // 32 chars
$sessionId = UploadSessionId::fromString($value);
expect($sessionId->value)->toBe($value);
});
it('compares session ids for equality', function () {
$value = str_repeat('a', 32);
$id1 = UploadSessionId::fromString($value);
$id2 = UploadSessionId::fromString($value);
$id3 = UploadSessionId::fromString(str_repeat('b', 32));
expect($id1->equals($id2))->toBeTrue();
expect($id1->equals($id3))->toBeFalse();
});
it('uses timing-safe comparison', function () {
// Test that equals() uses hash_equals for timing safety
$id1 = UploadSessionId::fromString(str_repeat('a', 32));
$id2 = UploadSessionId::fromString(str_repeat('a', 32));
// This tests the implementation detail that hash_equals is used
expect($id1->equals($id2))->toBeTrue();
});
it('converts to string via toString', function () {
$value = str_repeat('a', 32);
$sessionId = UploadSessionId::fromString($value);
expect($sessionId->toString())->toBe($value);
});
it('converts to string via __toString', function () {
$value = str_repeat('a', 32);
$sessionId = UploadSessionId::fromString($value);
expect((string) $sessionId)->toBe($value);
});
it('handles hex-encoded session ids', function () {
// Typical format from bin2hex(random_bytes(16))
$hexValue = bin2hex(random_bytes(16)); // 32 hex characters
$sessionId = UploadSessionId::fromString($hexValue);
expect(strlen($sessionId->value))->toBe(32);
expect(ctype_xdigit($sessionId->value))->toBeTrue();
});
it('rejects empty string', function () {
expect(fn() => UploadSessionId::fromString(''))
->toThrow(InvalidArgumentException::class);
});
it('rejects whitespace characters', function () {
$value = str_repeat('a', 31) . ' '; // 32 chars with space
expect(fn() => UploadSessionId::fromString($value))
->toThrow(InvalidArgumentException::class);
});
it('rejects special characters', function () {
$specialChars = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '+', '='];
foreach ($specialChars as $char) {
expect(fn() => UploadSessionId::fromString(str_repeat('a', 31) . $char))
->toThrow(InvalidArgumentException::class);
}
});
});

View File

@@ -0,0 +1,454 @@
<?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]);
});
});