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,314 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\LiveComponents\Cache\ComponentStateCache;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentState;
use App\Framework\StateManagement\InMemoryStateManager;
use App\Framework\StateManagement\StateManagerStatistics;
describe('ComponentStateCache with StateManager', function () {
beforeEach(function () {
// Create StateManager for ComponentState
$this->stateManager = InMemoryStateManager::for(ComponentState::class);
$this->cache = new ComponentStateCache($this->stateManager);
$this->componentId = ComponentId::create('counter', 'instance-1');
});
it('stores component state', function () {
$state = ComponentState::fromArray([
'count' => 5,
'lastUpdated' => time()
]);
$this->cache->store($this->componentId, $state);
expect($this->cache->has($this->componentId))->toBeTrue();
});
it('retrieves stored component state', function () {
$state = ComponentState::fromArray([
'count' => 10,
'label' => 'Counter'
]);
$this->cache->store($this->componentId, $state);
$retrieved = $this->cache->retrieve($this->componentId);
expect($retrieved)->toBeInstanceOf(ComponentState::class);
expect($retrieved->toArray()['count'])->toBe(10);
expect($retrieved->toArray()['label'])->toBe('Counter');
});
it('returns null for non-existent state', function () {
$nonExistentId = ComponentId::create('counter', 'does-not-exist');
$retrieved = $this->cache->retrieve($nonExistentId);
expect($retrieved)->toBeNull();
});
it('checks state existence', function () {
$state = ComponentState::fromArray(['value' => 42]);
expect($this->cache->has($this->componentId))->toBeFalse();
$this->cache->store($this->componentId, $state);
expect($this->cache->has($this->componentId))->toBeTrue();
});
it('invalidates cached state', function () {
$state = ComponentState::fromArray(['value' => 100]);
$this->cache->store($this->componentId, $state);
expect($this->cache->has($this->componentId))->toBeTrue();
$this->cache->invalidate($this->componentId);
expect($this->cache->has($this->componentId))->toBeFalse();
expect($this->cache->retrieve($this->componentId))->toBeNull();
});
it('clears all cached states', function () {
$state1 = ComponentState::fromArray(['value' => 1]);
$state2 = ComponentState::fromArray(['value' => 2]);
$id1 = ComponentId::create('counter', 'instance-1');
$id2 = ComponentId::create('counter', 'instance-2');
$this->cache->store($id1, $state1);
$this->cache->store($id2, $state2);
$this->cache->clear();
expect($this->cache->has($id1))->toBeFalse();
expect($this->cache->has($id2))->toBeFalse();
});
it('atomically updates component state', function () {
$initialState = ComponentState::fromArray([
'count' => 5,
'version' => 1
]);
$this->cache->store($this->componentId, $initialState);
$updatedState = $this->cache->update(
$this->componentId,
function ($state) {
if ($state === null) {
return ComponentState::fromArray(['count' => 1, 'version' => 1]);
}
$data = $state->toArray();
return ComponentState::fromArray([
'count' => $data['count'] + 1,
'version' => $data['version'] + 1
]);
}
);
expect($updatedState->toArray()['count'])->toBe(6);
expect($updatedState->toArray()['version'])->toBe(2);
});
it('handles null state in atomic update', function () {
$newState = $this->cache->update(
$this->componentId,
function ($state) {
return $state ?? ComponentState::fromArray([
'count' => 1,
'initialized' => true
]);
}
);
expect($newState->toArray()['count'])->toBe(1);
expect($newState->toArray()['initialized'])->toBe(true);
});
it('respects custom TTL', function () {
$state = ComponentState::fromArray(['value' => 42]);
$customTtl = Duration::fromMinutes(30);
$this->cache->store($this->componentId, $state, $customTtl);
expect($this->cache->has($this->componentId))->toBeTrue();
});
it('uses default TTL when not specified', function () {
$state = ComponentState::fromArray(['value' => 100]);
$this->cache->store($this->componentId, $state);
expect($this->cache->has($this->componentId))->toBeTrue();
});
it('provides cache statistics', function () {
$state = ComponentState::fromArray(['value' => 1]);
$this->cache->store($this->componentId, $state);
$this->cache->retrieve($this->componentId);
$this->cache->has($this->componentId);
$stats = $this->cache->getStats();
expect($stats)->toBeInstanceOf(StateManagerStatistics::class);
expect($stats->totalKeys)->toBeGreaterThanOrEqual(1);
expect($stats->setCount)->toBeGreaterThanOrEqual(1);
expect($stats->hitCount)->toBeGreaterThanOrEqual(1);
});
it('stores with automatic TTL based on component type', function () {
$state = ComponentState::fromArray(['value' => 42]);
$this->cache->storeWithAutoTTL($this->componentId, $state, 'counter');
expect($this->cache->has($this->componentId))->toBeTrue();
});
it('applies different TTLs for different component types', function () {
$timerState = ComponentState::fromArray(['seconds' => 30]);
$chartState = ComponentState::fromArray(['data' => [1, 2, 3]]);
$cardState = ComponentState::fromArray(['title' => 'Card Title']);
$timerId = ComponentId::create('timer', 'instance-1');
$chartId = ComponentId::create('chart', 'instance-1');
$cardId = ComponentId::create('card', 'instance-1');
// timer: 5 minutes TTL
$this->cache->storeWithAutoTTL($timerId, $timerState, 'timer');
// chart: 30 minutes TTL
$this->cache->storeWithAutoTTL($chartId, $chartState, 'chart');
// card: 2 hours TTL
$this->cache->storeWithAutoTTL($cardId, $cardState, 'card');
expect($this->cache->has($timerId))->toBeTrue();
expect($this->cache->has($chartId))->toBeTrue();
expect($this->cache->has($cardId))->toBeTrue();
});
it('uses default TTL for unknown component types', function () {
$state = ComponentState::fromArray(['value' => 123]);
$unknownId = ComponentId::create('unknown-type', 'instance-1');
$this->cache->storeWithAutoTTL($unknownId, $state, 'unknown-type');
expect($this->cache->has($unknownId))->toBeTrue();
});
it('generates consistent cache keys for same component', function () {
$state = ComponentState::fromArray(['value' => 42]);
$id1 = ComponentId::create('counter', 'instance-1');
$id2 = ComponentId::create('counter', 'instance-1');
$this->cache->store($id1, $state);
// Same component ID should find the cached state
expect($this->cache->has($id2))->toBeTrue();
expect($this->cache->retrieve($id2))->not->toBeNull();
});
it('generates different cache keys for different instances', function () {
$state1 = ComponentState::fromArray(['value' => 1]);
$state2 = ComponentState::fromArray(['value' => 2]);
$id1 = ComponentId::create('counter', 'instance-1');
$id2 = ComponentId::create('counter', 'instance-2');
$this->cache->store($id1, $state1);
$this->cache->store($id2, $state2);
$retrieved1 = $this->cache->retrieve($id1);
$retrieved2 = $this->cache->retrieve($id2);
expect($retrieved1->toArray()['value'])->toBe(1);
expect($retrieved2->toArray()['value'])->toBe(2);
});
it('handles complex state data', function () {
$complexState = ComponentState::fromArray([
'user' => [
'id' => 123,
'name' => 'John Doe',
'email' => 'john@example.com'
],
'preferences' => [
'theme' => 'dark',
'notifications' => true
],
'metadata' => [
'created_at' => time(),
'updated_at' => time()
]
]);
$this->cache->store($this->componentId, $complexState);
$retrieved = $this->cache->retrieve($this->componentId);
expect($retrieved->toArray()['user']['name'])->toBe('John Doe');
expect($retrieved->toArray()['preferences']['theme'])->toBe('dark');
expect($retrieved->toArray()['metadata'])->toHaveKey('created_at');
});
it('preserves state through multiple updates', function () {
$initialState = ComponentState::fromArray(['count' => 0]);
$this->cache->store($this->componentId, $initialState);
// Update 1
$this->cache->update(
$this->componentId,
fn($state) => ComponentState::fromArray(['count' => $state->toArray()['count'] + 1])
);
// Update 2
$this->cache->update(
$this->componentId,
fn($state) => ComponentState::fromArray(['count' => $state->toArray()['count'] + 1])
);
// Update 3
$finalState = $this->cache->update(
$this->componentId,
fn($state) => ComponentState::fromArray(['count' => $state->toArray()['count'] + 1])
);
expect($finalState->toArray()['count'])->toBe(3);
});
it('correctly reports statistics after various operations', function () {
$state = ComponentState::fromArray(['value' => 1]);
// Set operations
$this->cache->store($this->componentId, $state);
$this->cache->store($this->componentId, $state);
// Get operations
$this->cache->retrieve($this->componentId);
$this->cache->retrieve($this->componentId);
// Has operations
$this->cache->has($this->componentId);
// Update operation
$this->cache->update(
$this->componentId,
fn($s) => ComponentState::fromArray(['value' => 2])
);
$stats = $this->cache->getStats();
expect($stats->setCount)->toBeGreaterThanOrEqual(2);
expect($stats->hitCount)->toBeGreaterThanOrEqual(2);
expect($stats->updateCount)->toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,679 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\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\UploadSessionIdGenerator;
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
use App\Framework\LiveComponents\ValueObjects\UploadSession;
use App\Framework\Random\TestableRandomGenerator;
use Tests\Support\InMemoryUploadSessionStore;
use Tests\Support\InMemoryUploadProgressTracker;
use DateTimeImmutable;
/**
* Edge Cases & Error Recovery Tests for Chunked Upload System
*
* Tests critical edge cases and error recovery scenarios:
* - Concurrency issues (simultaneous uploads, race conditions)
* - Data corruption scenarios (partial writes, filesystem corruption)
* - Resource exhaustion (disk space, memory limits, session limits)
* - Network failure recovery (timeouts, resume, idempotency)
* - Stress & performance (simultaneous sessions, large files)
*/
beforeEach(function () {
// Setup dependencies
$this->randomGen = new TestableRandomGenerator();
$this->sessionIdGenerator = new UploadSessionIdGenerator($this->randomGen);
$this->sessionStore = new InMemoryUploadSessionStore();
$this->integrityValidator = new IntegrityValidator();
$this->fileStorage = new InMemoryStorage();
$this->chunkAssembler = new ChunkAssembler($this->fileStorage);
$this->progressTracker = new InMemoryUploadProgressTracker();
$this->uploadManager = new ChunkedUploadManager(
$this->sessionIdGenerator,
$this->sessionStore,
$this->integrityValidator,
$this->chunkAssembler,
$this->fileStorage,
$this->progressTracker,
'/tmp/test-uploads'
);
});
// ============================================================================
// CONCURRENCY EDGE CASES
// ============================================================================
describe('Concurrency Edge Cases', function () {
it('handles duplicate chunk upload (idempotency)', 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 same chunk twice (simulate network retry)
$result1 = $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunkData,
providedHash: $chunkHash
);
$result2 = $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunkData,
providedHash: $chunkHash
);
// Should be idempotent - both uploads succeed
expect($result1->getUploadedChunks())->toHaveCount(1);
expect($result2->getUploadedChunks())->toHaveCount(1);
expect($result1->getProgress())->toBe(50.0);
expect($result2->getProgress())->toBe(50.0);
});
it('rejects chunk upload with different data but same index', function () {
// Initialize session
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512)
);
// Upload first chunk
$chunkData1 = str_repeat('A', 512);
$chunkHash1 = ChunkHash::fromData($chunkData1);
$this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunkData1,
providedHash: $chunkHash1
);
// Try to upload different data for same chunk index
$chunkData2 = str_repeat('B', 512);
$chunkHash2 = ChunkHash::fromData($chunkData2);
expect(fn() => $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunkData2,
providedHash: $chunkHash2
))->toThrow(\InvalidArgumentException::class, 'already uploaded with different');
});
it('handles out-of-order chunk uploads', 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)
);
// Upload chunks in random order: 2, 0, 3, 1
$chunks = [
2 => str_repeat('C', 512),
0 => str_repeat('A', 512),
3 => str_repeat('D', 512),
1 => str_repeat('B', 512),
];
foreach ($chunks as $index => $data) {
$hash = ChunkHash::fromData($data);
$this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: $index,
chunkData: $data,
providedHash: $hash
);
}
// All chunks uploaded - should be complete
$finalSession = $this->uploadManager->getStatus($session->sessionId);
expect($finalSession->isComplete())->toBeTrue();
expect($finalSession->getUploadedChunks())->toHaveCount(4);
});
it('handles rapid successive chunk uploads', function () {
// Initialize session with many small chunks
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'rapid-upload.txt',
totalSize: Byte::fromBytes(1000),
chunkSize: Byte::fromBytes(100)
);
// Rapidly upload all 10 chunks
for ($i = 0; $i < 10; $i++) {
$chunkData = str_repeat(chr(65 + $i), 100);
$chunkHash = ChunkHash::fromData($chunkData);
$this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: $i,
chunkData: $chunkData,
providedHash: $chunkHash
);
}
$finalSession = $this->uploadManager->getStatus($session->sessionId);
expect($finalSession->isComplete())->toBeTrue();
expect($finalSession->getProgress())->toBe(100.0);
});
});
// ============================================================================
// DATA CORRUPTION & INTEGRITY EDGE CASES
// ============================================================================
describe('Data Corruption Edge Cases', function () {
it('detects corrupted chunk data via hash mismatch', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512)
);
// Create chunk with intentionally wrong hash
$chunkData = str_repeat('A', 512);
$corruptedHash = ChunkHash::fromData('corrupted');
expect(fn() => $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunkData,
providedHash: $corruptedHash
))->toThrow(\InvalidArgumentException::class, 'hash mismatch');
});
it('validates final assembled file hash', function () {
$chunk1Data = str_repeat('A', 512);
$chunk2Data = str_repeat('B', 512);
$expectedHash = ChunkHash::fromData($chunk1Data . $chunk2Data);
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'validated.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512),
expectedFileHash: $expectedHash
);
// Upload all chunks
$this->uploadManager->uploadChunk(
$session->sessionId,
0,
$chunk1Data,
ChunkHash::fromData($chunk1Data)
);
$this->uploadManager->uploadChunk(
$session->sessionId,
1,
$chunk2Data,
ChunkHash::fromData($chunk2Data)
);
// Complete upload - should succeed with matching hash
$targetPath = '/tmp/validated-file.txt';
$completedSession = $this->uploadManager->completeUpload(
$session->sessionId,
$targetPath
);
expect($completedSession->isComplete())->toBeTrue();
expect($this->fileStorage->exists($targetPath))->toBeTrue();
});
it('rejects final assembly with mismatched file hash', function () {
$wrongHash = ChunkHash::fromData('wrong expected hash');
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'mismatch.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512),
expectedFileHash: $wrongHash
);
// Upload all chunks
$chunk1Data = str_repeat('A', 512);
$chunk2Data = str_repeat('B', 512);
$this->uploadManager->uploadChunk(
$session->sessionId,
0,
$chunk1Data,
ChunkHash::fromData($chunk1Data)
);
$this->uploadManager->uploadChunk(
$session->sessionId,
1,
$chunk2Data,
ChunkHash::fromData($chunk2Data)
);
// Completion should fail due to hash mismatch
expect(fn() => $this->uploadManager->completeUpload(
$session->sessionId,
'/tmp/mismatch-file.txt'
))->toThrow(\InvalidArgumentException::class);
});
// Note: Chunk size validation removed - not feasible because:
// 1. Session doesn't store original chunkSize parameter
// 2. Client determines chunk size, server only calculates totalChunks
// 3. Last chunk can be any size
// 4. Hash validation provides sufficient integrity guarantee
it('handles last chunk with partial size correctly', function () {
// Initialize with size not evenly divisible by chunk size
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'partial-last.txt',
totalSize: Byte::fromBytes(1300), // 512 + 512 + 276
chunkSize: Byte::fromBytes(512)
);
expect($session->totalChunks)->toBe(3);
// Upload first two full chunks
$this->uploadManager->uploadChunk(
$session->sessionId,
0,
str_repeat('A', 512),
ChunkHash::fromData(str_repeat('A', 512))
);
$this->uploadManager->uploadChunk(
$session->sessionId,
1,
str_repeat('B', 512),
ChunkHash::fromData(str_repeat('B', 512))
);
// Upload last chunk with partial size (276 bytes)
$lastChunkData = str_repeat('C', 276);
$lastChunkHash = ChunkHash::fromData($lastChunkData);
$finalSession = $this->uploadManager->uploadChunk(
$session->sessionId,
2,
$lastChunkData,
$lastChunkHash
);
expect($finalSession->isComplete())->toBeTrue();
expect($finalSession->getProgress())->toBe(100.0);
});
});
// ============================================================================
// RESOURCE EXHAUSTION EDGE CASES
// ============================================================================
describe('Resource Exhaustion Edge Cases', function () {
it('handles zero-byte file gracefully', function () {
expect(fn() => $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'empty.txt',
totalSize: Byte::fromBytes(0),
chunkSize: Byte::fromBytes(512)
))->toThrow(\InvalidArgumentException::class, 'Total size must be greater than zero');
});
it('handles extremely small chunk size', function () {
expect(fn() => $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'tiny-chunks.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(0)
))->toThrow(\InvalidArgumentException::class, 'Chunk size must be greater than zero');
});
it('handles very large file simulation (1GB+ chunks)', function () {
// Simulate 1GB file with 100MB chunks
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'large-file.bin',
totalSize: Byte::fromGigabytes(1),
chunkSize: Byte::fromMegabytes(100)
);
// 1GB = 1,073,741,824 bytes / 100MB = 104,857,600 bytes = 10.24 chunks
// ceil(10.24) = 11 chunks (not evenly divisible)
expect($session->totalChunks)->toBe(11);
expect($session->totalSize->toGigabytes())->toBe(1.0);
});
it('calculates correct chunk count for various file sizes', function () {
// Test various size combinations
$testCases = [
['total' => 1000, 'chunk' => 100, 'expected' => 10], // Exact division
['total' => 1050, 'chunk' => 100, 'expected' => 11], // Partial last chunk
['total' => 999, 'chunk' => 100, 'expected' => 10], // Partial last chunk
['total' => 100, 'chunk' => 100, 'expected' => 1], // Single chunk
['total' => 50, 'chunk' => 100, 'expected' => 1], // Chunk larger than file
];
foreach ($testCases as $case) {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test.txt',
totalSize: Byte::fromBytes($case['total']),
chunkSize: Byte::fromBytes($case['chunk'])
);
expect($session->totalChunks)->toBe(
$case['expected'],
"Failed for total={$case['total']}, chunk={$case['chunk']}"
);
}
});
it('handles many simultaneous upload sessions', function () {
$sessions = [];
// Create 50 simultaneous upload sessions
for ($i = 0; $i < 50; $i++) {
$sessions[] = $this->uploadManager->initializeUpload(
componentId: "uploader-{$i}",
fileName: "file-{$i}.txt",
totalSize: Byte::fromKilobytes(10),
chunkSize: Byte::fromKilobytes(2)
);
}
expect($sessions)->toHaveCount(50);
// Verify all sessions are independent
foreach ($sessions as $session) {
expect($session->sessionId)->not->toBeNull();
$retrieved = $this->uploadManager->getStatus($session->sessionId);
expect($retrieved)->not->toBeNull();
}
});
});
// ============================================================================
// NETWORK FAILURE & RECOVERY EDGE CASES
// ============================================================================
describe('Network Failure & Recovery Edge Cases', function () {
it('handles session expiration gracefully', function () {
// Create expired session manually
$sessionId = $this->sessionIdGenerator->generate();
$expiredSession = new UploadSession(
sessionId: $sessionId,
componentId: 'test-uploader',
fileName: 'expired.txt',
totalSize: Byte::fromBytes(1024),
totalChunks: 2,
createdAt: new DateTimeImmutable('-2 hours'),
expiresAt: new DateTimeImmutable('-1 hour') // Expired 1 hour ago
);
$this->sessionStore->save($expiredSession);
// Try to upload chunk for expired session
$chunkData = str_repeat('A', 512);
$chunkHash = ChunkHash::fromData($chunkData);
expect(fn() => $this->uploadManager->uploadChunk(
sessionId: $sessionId,
chunkIndex: 0,
chunkData: $chunkData,
providedHash: $chunkHash
))->toThrow(\InvalidArgumentException::class, 'expired');
});
it('supports resume after partial upload', function () {
// Initialize and upload first chunk
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'resumable.txt',
totalSize: Byte::fromBytes(1536), // 3 chunks of 512 bytes
chunkSize: Byte::fromBytes(512)
);
// Upload first chunk
$this->uploadManager->uploadChunk(
$session->sessionId,
0,
str_repeat('A', 512),
ChunkHash::fromData(str_repeat('A', 512))
);
// Simulate network interruption - check status
$currentSession = $this->uploadManager->getStatus($session->sessionId);
$missingChunks = $currentSession->getMissingChunkIndices();
expect($missingChunks)->toBe([1, 2]);
expect($currentSession->getProgress())->toBeGreaterThan(0);
expect($currentSession->getProgress())->toBeLessThan(100.0);
// Resume - upload remaining chunks
$this->uploadManager->uploadChunk(
$session->sessionId,
1,
str_repeat('B', 512),
ChunkHash::fromData(str_repeat('B', 512))
);
$this->uploadManager->uploadChunk(
$session->sessionId,
2,
str_repeat('C', 512),
ChunkHash::fromData(str_repeat('C', 512))
);
$finalSession = $this->uploadManager->getStatus($session->sessionId);
expect($finalSession->isComplete())->toBeTrue();
});
it('handles abort during active upload', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'aborted.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512)
);
// Upload first chunk
$this->uploadManager->uploadChunk(
$session->sessionId,
0,
str_repeat('A', 512),
ChunkHash::fromData(str_repeat('A', 512))
);
// Abort mid-upload
$this->uploadManager->abortUpload(
sessionId: $session->sessionId,
reason: 'User cancelled'
);
// Verify session is completely removed
expect($this->uploadManager->getStatus($session->sessionId))->toBeNull();
// Verify cannot upload to aborted session
expect(fn() => $this->uploadManager->uploadChunk(
$session->sessionId,
1,
str_repeat('B', 512),
ChunkHash::fromData(str_repeat('B', 512))
))->toThrow(\InvalidArgumentException::class, 'Session not found');
});
it('validates session exists before chunk upload', function () {
$nonExistentSessionId = $this->sessionIdGenerator->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');
});
it('handles rapid abort-reinitialize cycles', function () {
// Initialize, upload chunk, abort - repeat multiple times
for ($i = 0; $i < 5; $i++) {
$session = $this->uploadManager->initializeUpload(
componentId: "uploader-{$i}",
fileName: "cycle-{$i}.txt",
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512)
);
// Upload one chunk
$this->uploadManager->uploadChunk(
$session->sessionId,
0,
str_repeat('A', 512),
ChunkHash::fromData(str_repeat('A', 512))
);
// Abort immediately
$this->uploadManager->abortUpload($session->sessionId);
// Verify cleanup
expect($this->uploadManager->getStatus($session->sessionId))->toBeNull();
}
// All abort cycles completed successfully
expect(true)->toBeTrue();
});
});
// ============================================================================
// STRESS & PERFORMANCE EDGE CASES
// ============================================================================
describe('Stress & Performance Edge Cases', function () {
it('handles burst of chunk uploads for same session', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'stress-test',
fileName: 'burst-upload.txt',
totalSize: Byte::fromBytes(5000),
chunkSize: Byte::fromBytes(100)
);
// Burst upload all 50 chunks as fast as possible
for ($i = 0; $i < 50; $i++) {
$chunkData = str_repeat(chr(65 + ($i % 26)), 100);
$chunkHash = ChunkHash::fromData($chunkData);
$this->uploadManager->uploadChunk(
$session->sessionId,
$i,
$chunkData,
$chunkHash
);
}
$finalSession = $this->uploadManager->getStatus($session->sessionId);
expect($finalSession->isComplete())->toBeTrue();
expect($finalSession->getUploadedChunks())->toHaveCount(50);
});
it('maintains session isolation across multiple uploads', function () {
// Create multiple sessions and upload chunks in interleaved manner
$session1 = $this->uploadManager->initializeUpload(
componentId: 'uploader-1',
fileName: 'file-1.txt',
totalSize: Byte::fromBytes(300),
chunkSize: Byte::fromBytes(100)
);
$session2 = $this->uploadManager->initializeUpload(
componentId: 'uploader-2',
fileName: 'file-2.txt',
totalSize: Byte::fromBytes(300),
chunkSize: Byte::fromBytes(100)
);
// Interleaved uploads: session1 chunk 0, session2 chunk 0, session1 chunk 1, etc.
$this->uploadManager->uploadChunk($session1->sessionId, 0, str_repeat('A', 100), ChunkHash::fromData(str_repeat('A', 100)));
$this->uploadManager->uploadChunk($session2->sessionId, 0, str_repeat('X', 100), ChunkHash::fromData(str_repeat('X', 100)));
$this->uploadManager->uploadChunk($session1->sessionId, 1, str_repeat('B', 100), ChunkHash::fromData(str_repeat('B', 100)));
$this->uploadManager->uploadChunk($session2->sessionId, 1, str_repeat('Y', 100), ChunkHash::fromData(str_repeat('Y', 100)));
$this->uploadManager->uploadChunk($session1->sessionId, 2, str_repeat('C', 100), ChunkHash::fromData(str_repeat('C', 100)));
$this->uploadManager->uploadChunk($session2->sessionId, 2, str_repeat('Z', 100), ChunkHash::fromData(str_repeat('Z', 100)));
// Both sessions should be complete and independent
$status1 = $this->uploadManager->getStatus($session1->sessionId);
$status2 = $this->uploadManager->getStatus($session2->sessionId);
expect($status1->isComplete())->toBeTrue();
expect($status2->isComplete())->toBeTrue();
// Complete both and verify file content
$path1 = '/tmp/file-1.txt';
$path2 = '/tmp/file-2.txt';
$this->uploadManager->completeUpload($session1->sessionId, $path1);
$this->uploadManager->completeUpload($session2->sessionId, $path2);
expect($this->fileStorage->get($path1))->toBe(str_repeat('A', 100) . str_repeat('B', 100) . str_repeat('C', 100));
expect($this->fileStorage->get($path2))->toBe(str_repeat('X', 100) . str_repeat('Y', 100) . str_repeat('Z', 100));
});
it('tracks progress accurately across many chunks', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'progress-test',
fileName: 'many-chunks.txt',
totalSize: Byte::fromBytes(10000),
chunkSize: Byte::fromBytes(100)
);
expect($session->totalChunks)->toBe(100);
// Upload chunks and verify progress increments correctly
$expectedProgress = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
$actualProgress = [];
for ($i = 0; $i < 100; $i++) {
$chunkData = str_repeat('X', 100);
$chunkHash = ChunkHash::fromData($chunkData);
$updatedSession = $this->uploadManager->uploadChunk(
$session->sessionId,
$i,
$chunkData,
$chunkHash
);
// Sample progress at 10% intervals
if (($i + 1) % 10 === 0) {
$actualProgress[] = $updatedSession->getProgress();
}
}
expect($actualProgress)->toBe($expectedProgress);
});
});

View File

@@ -0,0 +1,387 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\LiveComponents\Services\UploadSessionIdGenerator;
use App\Framework\LiveComponents\ValueObjects\ChunkMetadata;
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
use App\Framework\LiveComponents\ValueObjects\UploadSession;
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
use App\Framework\LiveComponents\ValueObjects\QuarantineStatus;
use App\Framework\Random\RandomGenerator;
use Tests\Support\InMemoryUploadSessionStore;
describe('InMemoryUploadSessionStore', function () {
beforeEach(function () {
$this->store = new InMemoryUploadSessionStore();
// Create RandomGenerator for UploadSessionId generation
$this->randomGen = new class implements RandomGenerator {
public function bytes(int $length): string
{
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$this->sessionIdGenerator = new UploadSessionIdGenerator($this->randomGen);
});
it('saves and retrieves session', function () {
$sessionId = $this->sessionIdGenerator->generate();
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'test-component',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 10
);
$this->store->save($session);
$retrieved = $this->store->get($sessionId);
expect($retrieved !== null)->toBeTrue();
expect($retrieved->sessionId->equals($sessionId))->toBeTrue();
expect($retrieved->fileName)->toBe('test.pdf');
expect($retrieved->totalChunks)->toBe(10);
});
it('returns null for non-existent session', function () {
$sessionId = $this->sessionIdGenerator->generate();
$retrieved = $this->store->get($sessionId);
expect($retrieved)->toBeNull();
});
it('checks session existence correctly', function () {
$sessionId = $this->sessionIdGenerator->generate();
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'test-component',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 10
);
expect($this->store->exists($sessionId))->toBeFalse();
$this->store->save($session);
expect($this->store->exists($sessionId))->toBeTrue();
});
it('deletes session', function () {
$sessionId = $this->sessionIdGenerator->generate();
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'test-component',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 10
);
$this->store->save($session);
expect($this->store->exists($sessionId))->toBeTrue();
$this->store->delete($sessionId);
expect($this->store->exists($sessionId))->toBeFalse();
expect($this->store->get($sessionId))->toBeNull();
});
it('deletes non-existent session without error', function () {
$sessionId = $this->sessionIdGenerator->generate();
expect($this->store->exists($sessionId))->toBeFalse();
$this->store->delete($sessionId);
expect($this->store->exists($sessionId))->toBeFalse();
});
it('updates existing session', function () {
$sessionId = $this->sessionIdGenerator->generate();
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'test-component',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 10
);
$this->store->save($session);
// Add chunk to session
$chunk = ChunkMetadata::create(
index: 0,
size: Byte::fromKilobytes(512),
hash: ChunkHash::fromData('chunk data')
);
$updatedSession = $session->withChunk($chunk);
$this->store->save($updatedSession);
$retrieved = $this->store->get($sessionId);
expect($retrieved !== null)->toBeTrue();
expect(count($retrieved->chunks))->toBe(1);
expect($retrieved->chunks[0]->index)->toBe(0);
});
it('cleans up expired sessions', function () {
// Create expired session (TTL in past)
$expiredSessionId = $this->sessionIdGenerator->generate();
$expiredSession = new UploadSession(
sessionId: $expiredSessionId,
componentId: 'test-component',
fileName: 'expired.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 10,
createdAt: new DateTimeImmutable('-2 hours'),
expiresAt: new DateTimeImmutable('-1 hour')
);
// Create valid session
$validSessionId = $this->sessionIdGenerator->generate();
$validSession = UploadSession::create(
sessionId: $validSessionId,
componentId: 'test-component',
fileName: 'valid.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 10
);
$this->store->save($expiredSession);
$this->store->save($validSession);
expect($this->store->count())->toBe(2);
$cleaned = $this->store->cleanupExpired();
expect($cleaned)->toBe(1);
expect($this->store->count())->toBe(1);
expect($this->store->exists($expiredSessionId))->toBeFalse();
expect($this->store->exists($validSessionId))->toBeTrue();
});
it('cleanup returns zero when no expired sessions', function () {
$sessionId = $this->sessionIdGenerator->generate();
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'test-component',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 10
);
$this->store->save($session);
$cleaned = $this->store->cleanupExpired();
expect($cleaned)->toBe(0);
expect($this->store->count())->toBe(1);
});
it('cleanup works on empty store', function () {
expect($this->store->count())->toBe(0);
$cleaned = $this->store->cleanupExpired();
expect($cleaned)->toBe(0);
expect($this->store->count())->toBe(0);
});
it('handles multiple sessions', function () {
$sessions = [];
for ($i = 0; $i < 5; $i++) {
$sessionId = $this->sessionIdGenerator->generate();
$session = UploadSession::create(
sessionId: $sessionId,
componentId: "component-{$i}",
fileName: "file-{$i}.pdf",
totalSize: Byte::fromMegabytes($i + 1),
totalChunks: ($i + 1) * 2
);
$sessions[] = $session;
$this->store->save($session);
}
expect($this->store->count())->toBe(5);
foreach ($sessions as $session) {
$retrieved = $this->store->get($session->sessionId);
expect($retrieved !== null)->toBeTrue();
expect($retrieved->sessionId->equals($session->sessionId))->toBeTrue();
}
});
it('getAll returns all sessions as array', function () {
$session1 = UploadSession::create(
sessionId: $this->sessionIdGenerator->generate(),
componentId: 'component-1',
fileName: 'file1.pdf',
totalSize: Byte::fromMegabytes(1),
totalChunks: 2
);
$session2 = UploadSession::create(
sessionId: $this->sessionIdGenerator->generate(),
componentId: 'component-2',
fileName: 'file2.pdf',
totalSize: Byte::fromMegabytes(2),
totalChunks: 4
);
$this->store->save($session1);
$this->store->save($session2);
$allSessions = $this->store->getAll();
expect($allSessions)->toBeArray();
expect(count($allSessions))->toBe(2);
expect($allSessions[0])->toBeInstanceOf(UploadSession::class);
expect($allSessions[1])->toBeInstanceOf(UploadSession::class);
});
it('count returns correct session count', function () {
expect($this->store->count())->toBe(0);
$session1 = UploadSession::create(
sessionId: $this->sessionIdGenerator->generate(),
componentId: 'component-1',
fileName: 'file1.pdf',
totalSize: Byte::fromMegabytes(1),
totalChunks: 2
);
$this->store->save($session1);
expect($this->store->count())->toBe(1);
$session2 = UploadSession::create(
sessionId: $this->sessionIdGenerator->generate(),
componentId: 'component-2',
fileName: 'file2.pdf',
totalSize: Byte::fromMegabytes(2),
totalChunks: 4
);
$this->store->save($session2);
expect($this->store->count())->toBe(2);
$this->store->delete($session1->sessionId);
expect($this->store->count())->toBe(1);
});
it('clear removes all sessions', function () {
for ($i = 0; $i < 3; $i++) {
$session = UploadSession::create(
sessionId: $this->sessionIdGenerator->generate(),
componentId: "component-{$i}",
fileName: "file-{$i}.pdf",
totalSize: Byte::fromMegabytes($i + 1),
totalChunks: ($i + 1) * 2
);
$this->store->save($session);
}
expect($this->store->count())->toBe(3);
$this->store->clear();
expect($this->store->count())->toBe(0);
expect($this->store->getAll())->toBe([]);
});
it('handles session with quarantine status', function () {
$sessionId = $this->sessionIdGenerator->generate();
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'test-component',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 10
);
// Proper lifecycle: PENDING → SCANNING → APPROVED
$scanningSession = $session->withQuarantineStatus(QuarantineStatus::SCANNING);
$approvedSession = $scanningSession->withQuarantineStatus(QuarantineStatus::APPROVED);
$this->store->save($approvedSession);
$retrieved = $this->store->get($sessionId);
expect($retrieved !== null)->toBeTrue();
expect($retrieved->quarantineStatus)->toBe(QuarantineStatus::APPROVED);
});
it('handles session with chunks', function () {
$sessionId = $this->sessionIdGenerator->generate();
$session = UploadSession::create(
sessionId: $sessionId,
componentId: 'test-component',
fileName: 'test.pdf',
totalSize: Byte::fromMegabytes(5),
totalChunks: 3
);
// Add chunks
$chunk1 = ChunkMetadata::create(
index: 0,
size: Byte::fromKilobytes(512),
hash: ChunkHash::fromData('chunk 0 data')
);
$chunk2 = ChunkMetadata::create(
index: 1,
size: Byte::fromKilobytes(512),
hash: ChunkHash::fromData('chunk 1 data')
);
$sessionWithChunks = $session->withChunk($chunk1)->withChunk($chunk2);
$this->store->save($sessionWithChunks);
$retrieved = $this->store->get($sessionId);
expect($retrieved !== null)->toBe(true);
expect(count($retrieved->chunks))->toBe(2);
});
it('isolates sessions by different session IDs', function () {
$sessionId1 = $this->sessionIdGenerator->generate();
$sessionId2 = $this->sessionIdGenerator->generate();
$session1 = UploadSession::create(
sessionId: $sessionId1,
componentId: 'component-1',
fileName: 'file1.pdf',
totalSize: Byte::fromMegabytes(1),
totalChunks: 2
);
$session2 = UploadSession::create(
sessionId: $sessionId2,
componentId: 'component-2',
fileName: 'file2.pdf',
totalSize: Byte::fromMegabytes(2),
totalChunks: 4
);
$this->store->save($session1);
$this->store->save($session2);
$retrieved1 = $this->store->get($sessionId1);
$retrieved2 = $this->store->get($sessionId2);
expect($retrieved1->fileName)->toBe('file1.pdf');
expect($retrieved2->fileName)->toBe('file2.pdf');
expect($retrieved1->totalChunks)->toBe(2);
expect($retrieved2->totalChunks)->toBe(4);
});
});

View File

@@ -0,0 +1,594 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Observability\ComponentMetricsCollector;
use App\Framework\Metrics\MetricType;
describe('ComponentMetricsCollector', function () {
beforeEach(function () {
$this->collector = new ComponentMetricsCollector();
});
describe('Render Metrics', function () {
it('records total renders with cached/uncached distinction', function () {
$this->collector->recordRender('user-stats', 45.5, false);
$this->collector->recordRender('user-stats', 12.3, true);
$this->collector->recordRender('dashboard', 78.9, false);
$metrics = $this->collector->getMetrics();
// Check total renders metrics exist (labels alphabetically sorted)
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=false,component_id=user-stats}');
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=true,component_id=user-stats}');
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=false,component_id=dashboard}');
// Verify counter values
expect($metrics['livecomponent_renders_total{cached=false,component_id=user-stats}']->value)->toBe(1.0);
expect($metrics['livecomponent_renders_total{cached=true,component_id=user-stats}']->value)->toBe(1.0);
});
it('records render duration histogram', function () {
$this->collector->recordRender('counter', 23.45, false);
$metrics = $this->collector->getMetrics();
// Check duration histogram exists (labels alphabetically sorted)
expect($metrics)->toHaveKey('livecomponent_render_duration_ms{cached=false,component_id=counter}');
$durationMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=counter}'];
expect($durationMetric->type)->toBe(MetricType::HISTOGRAM);
expect($durationMetric->value)->toBe(23.45);
expect($durationMetric->unit)->toBe('ms');
});
it('distinguishes between cached and uncached renders', function () {
$this->collector->recordRender('product-list', 120.0, false); // Uncached
$this->collector->recordRender('product-list', 5.0, true); // Cached
$metrics = $this->collector->getMetrics();
// Labels alphabetically sorted
$uncachedDuration = $metrics['livecomponent_render_duration_ms{cached=false,component_id=product-list}'];
$cachedDuration = $metrics['livecomponent_render_duration_ms{cached=true,component_id=product-list}'];
expect($uncachedDuration->value)->toBe(120.0);
expect($cachedDuration->value)->toBe(5.0);
});
});
describe('Action Metrics', function () {
it('records action executions with success/error status', function () {
$this->collector->recordAction('form', 'validate', 15.5, true);
$this->collector->recordAction('form', 'submit', 45.0, false);
$this->collector->recordAction('form', 'validate', 12.0, true);
$metrics = $this->collector->getMetrics();
// Check action totals
expect($metrics)->toHaveKey('livecomponent_actions_total{action=validate,component_id=form,status=success}');
expect($metrics)->toHaveKey('livecomponent_actions_total{action=submit,component_id=form,status=error}');
// Verify counter values
expect($metrics['livecomponent_actions_total{action=validate,component_id=form,status=success}']->value)->toBe(2.0);
expect($metrics['livecomponent_actions_total{action=submit,component_id=form,status=error}']->value)->toBe(1.0);
});
it('records action duration histogram', function () {
$this->collector->recordAction('cart', 'addItem', 34.56, true);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_action_duration_ms{action=addItem,component_id=cart}');
$durationMetric = $metrics['livecomponent_action_duration_ms{action=addItem,component_id=cart}'];
expect($durationMetric->type)->toBe(MetricType::HISTOGRAM);
expect($durationMetric->value)->toBe(34.56);
});
it('increments action error counter on failure', function () {
$this->collector->recordAction('payment', 'charge', 100.0, false);
$this->collector->recordAction('payment', 'charge', 95.0, false);
$metrics = $this->collector->getMetrics();
// Check error counter
expect($metrics)->toHaveKey('livecomponent_action_errors_total{action=charge,component_id=payment}');
expect($metrics['livecomponent_action_errors_total{action=charge,component_id=payment}']->value)->toBe(2.0);
});
it('does not increment error counter on success', function () {
$this->collector->recordAction('search', 'query', 50.0, true);
$metrics = $this->collector->getMetrics();
// Error counter should not exist
expect($metrics)->not->toHaveKey('livecomponent_action_errors_total{action=query,component_id=search}');
});
});
describe('Cache Metrics', function () {
it('records cache hits', function () {
$this->collector->recordCacheHit('user-profile', true);
$this->collector->recordCacheHit('user-profile', true);
$this->collector->recordCacheHit('dashboard', true);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_cache_hits_total{component_id=user-profile}');
expect($metrics['livecomponent_cache_hits_total{component_id=user-profile}']->value)->toBe(2.0);
expect($metrics['livecomponent_cache_hits_total{component_id=dashboard}']->value)->toBe(1.0);
});
it('records cache misses', function () {
$this->collector->recordCacheHit('stats', false);
$this->collector->recordCacheHit('stats', false);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_cache_misses_total{component_id=stats}');
expect($metrics['livecomponent_cache_misses_total{component_id=stats}']->value)->toBe(2.0);
});
it('tracks mixed cache hits and misses', function () {
$this->collector->recordCacheHit('widget', true); // Hit
$this->collector->recordCacheHit('widget', false); // Miss
$this->collector->recordCacheHit('widget', true); // Hit
$metrics = $this->collector->getMetrics();
expect($metrics['livecomponent_cache_hits_total{component_id=widget}']->value)->toBe(2.0);
expect($metrics['livecomponent_cache_misses_total{component_id=widget}']->value)->toBe(1.0);
});
});
describe('Event Metrics', function () {
it('records dispatched events', function () {
$this->collector->recordEventDispatched('chat', 'message.sent');
$this->collector->recordEventDispatched('chat', 'message.sent');
$this->collector->recordEventDispatched('chat', 'user.typing');
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_events_dispatched_total{component_id=chat,event=message.sent}');
expect($metrics['livecomponent_events_dispatched_total{component_id=chat,event=message.sent}']->value)->toBe(2.0);
expect($metrics['livecomponent_events_dispatched_total{component_id=chat,event=user.typing}']->value)->toBe(1.0);
});
it('records received events', function () {
$this->collector->recordEventReceived('notification', 'alert.received');
$this->collector->recordEventReceived('notification', 'alert.received');
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_events_received_total{component_id=notification,event=alert.received}');
expect($metrics['livecomponent_events_received_total{component_id=notification,event=alert.received}']->value)->toBe(2.0);
});
});
describe('Hydration Metrics', function () {
it('records hydration duration', function () {
$this->collector->recordHydration('interactive-map', 156.78);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_hydration_duration_ms{component_id=interactive-map}');
$metric = $metrics['livecomponent_hydration_duration_ms{component_id=interactive-map}'];
expect($metric->type)->toBe(MetricType::HISTOGRAM);
expect($metric->value)->toBe(156.78);
});
});
describe('Batch Metrics', function () {
it('records batch operations with success/failure counts', function () {
$this->collector->recordBatch(
operationCount: 5,
durationMs: 123.45,
successCount: 4,
failureCount: 1
);
$metrics = $this->collector->getMetrics();
// Check batch operation counter
expect($metrics)->toHaveKey('livecomponent_batch_operations_total{status=executed}');
expect($metrics['livecomponent_batch_operations_total{status=executed}']->value)->toBe(1.0);
// Check batch size histogram
expect($metrics)->toHaveKey('livecomponent_batch_size');
expect($metrics['livecomponent_batch_size']->value)->toBe(5.0);
// Check batch duration histogram
expect($metrics)->toHaveKey('livecomponent_batch_duration_ms');
expect($metrics['livecomponent_batch_duration_ms']->value)->toBe(123.45);
// Check success/failure counters
expect($metrics)->toHaveKey('livecomponent_batch_success_total');
expect($metrics['livecomponent_batch_success_total']->value)->toBe(4.0);
expect($metrics)->toHaveKey('livecomponent_batch_failure_total');
expect($metrics['livecomponent_batch_failure_total']->value)->toBe(1.0);
});
it('records multiple batch operations', function () {
$this->collector->recordBatch(3, 50.0, 3, 0);
$this->collector->recordBatch(7, 150.0, 5, 2);
$metrics = $this->collector->getMetrics();
// Batch operations counter should increment
expect($metrics['livecomponent_batch_operations_total{status=executed}']->value)->toBe(2.0);
// Success/failure totals should accumulate
expect($metrics['livecomponent_batch_success_total']->value)->toBe(8.0); // 3 + 5
expect($metrics['livecomponent_batch_failure_total']->value)->toBe(2.0);
});
it('handles batch with no failures', function () {
$this->collector->recordBatch(10, 200.0, 10, 0);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_batch_success_total');
expect($metrics)->not->toHaveKey('livecomponent_batch_failure_total');
});
it('handles batch with no successes', function () {
$this->collector->recordBatch(5, 100.0, 0, 5);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_batch_failure_total');
expect($metrics)->not->toHaveKey('livecomponent_batch_success_total');
});
});
describe('Fragment Metrics', function () {
it('records fragment updates with count and duration', function () {
$this->collector->recordFragmentUpdate('dashboard', 3, 45.67);
$metrics = $this->collector->getMetrics();
// Check fragment update counter
expect($metrics)->toHaveKey('livecomponent_fragment_updates_total{component_id=dashboard}');
expect($metrics['livecomponent_fragment_updates_total{component_id=dashboard}']->value)->toBe(1.0);
// Check fragment count histogram
expect($metrics)->toHaveKey('livecomponent_fragment_count');
expect($metrics['livecomponent_fragment_count']->value)->toBe(3.0);
// Check fragment duration histogram
expect($metrics)->toHaveKey('livecomponent_fragment_duration_ms');
expect($metrics['livecomponent_fragment_duration_ms']->value)->toBe(45.67);
});
it('tracks multiple fragment updates', function () {
$this->collector->recordFragmentUpdate('editor', 2, 30.0);
$this->collector->recordFragmentUpdate('editor', 5, 60.0);
$metrics = $this->collector->getMetrics();
expect($metrics['livecomponent_fragment_updates_total{component_id=editor}']->value)->toBe(2.0);
});
});
describe('Upload Metrics', function () {
it('records successful upload chunks', function () {
$this->collector->recordUploadChunk('session-123', 0, 45.6, true);
$this->collector->recordUploadChunk('session-123', 1, 42.3, true);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_upload_chunks_total{session_id=session-123,status=success}');
expect($metrics['livecomponent_upload_chunks_total{session_id=session-123,status=success}']->value)->toBe(2.0);
expect($metrics)->toHaveKey('livecomponent_upload_chunk_duration_ms{session_id=session-123}');
});
it('records failed upload chunks', function () {
$this->collector->recordUploadChunk('session-456', 0, 100.0, false);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_upload_chunks_total{session_id=session-456,status=error}');
expect($metrics['livecomponent_upload_chunks_total{session_id=session-456,status=error}']->value)->toBe(1.0);
});
it('records upload completion', function () {
$this->collector->recordUploadComplete('session-789', 5432.1, 10);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_uploads_completed_total{session_id=session-789}');
expect($metrics['livecomponent_uploads_completed_total{session_id=session-789}']->value)->toBe(1.0);
expect($metrics)->toHaveKey('livecomponent_upload_total_duration_ms');
expect($metrics['livecomponent_upload_total_duration_ms']->value)->toBe(5432.1);
expect($metrics)->toHaveKey('livecomponent_upload_chunk_count');
expect($metrics['livecomponent_upload_chunk_count']->value)->toBe(10.0);
});
});
describe('Summary Generation', function () {
it('generates summary with aggregated statistics', function () {
// Create variety of metrics
$this->collector->recordRender('comp-1', 50.0, false);
$this->collector->recordRender('comp-1', 10.0, true);
$this->collector->recordAction('comp-1', 'action1', 30.0, true);
$this->collector->recordAction('comp-1', 'action2', 40.0, false);
$this->collector->recordCacheHit('comp-1', true);
$this->collector->recordCacheHit('comp-1', true);
$this->collector->recordCacheHit('comp-1', false);
$this->collector->recordEventDispatched('comp-1', 'event1');
$summary = $this->collector->getSummary();
expect($summary)->toHaveKey('total_renders');
expect($summary)->toHaveKey('total_actions');
expect($summary)->toHaveKey('cache_hits');
expect($summary)->toHaveKey('cache_misses');
expect($summary)->toHaveKey('total_events');
expect($summary)->toHaveKey('action_errors');
expect($summary)->toHaveKey('cache_hit_rate');
expect($summary['total_renders'])->toBe(2);
expect($summary['total_actions'])->toBe(2);
expect($summary['cache_hits'])->toBe(2);
expect($summary['cache_misses'])->toBe(1);
expect($summary['total_events'])->toBe(1);
expect($summary['action_errors'])->toBe(1);
});
it('calculates cache hit rate correctly', function () {
$this->collector->recordCacheHit('test', true);
$this->collector->recordCacheHit('test', true);
$this->collector->recordCacheHit('test', true);
$this->collector->recordCacheHit('test', false);
$summary = $this->collector->getSummary();
// 3 hits / 4 total = 75%
expect($summary['cache_hit_rate'])->toBe(75.0);
});
it('handles zero cache accesses gracefully', function () {
$this->collector->recordRender('test', 10.0);
$summary = $this->collector->getSummary();
expect($summary['cache_hit_rate'])->toBe(0.0);
});
it('returns zero values for empty collector', function () {
$summary = $this->collector->getSummary();
expect($summary['total_renders'])->toBe(0);
expect($summary['total_actions'])->toBe(0);
expect($summary['cache_hits'])->toBe(0);
expect($summary['cache_misses'])->toBe(0);
expect($summary['total_events'])->toBe(0);
expect($summary['action_errors'])->toBe(0);
expect($summary['cache_hit_rate'])->toBe(0.0);
});
});
describe('Prometheus Export', function () {
it('exports metrics in Prometheus format', function () {
$this->collector->recordRender('test-component', 45.5, false);
$this->collector->recordAction('test-component', 'increment', 12.3, true);
$prometheus = $this->collector->exportPrometheus();
// Check Prometheus format
expect($prometheus)->toContain('# HELP LiveComponents metrics');
expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram');
// Check metric lines exist
expect($prometheus)->toContain('livecomponent_renders_total');
expect($prometheus)->toContain('livecomponent_actions_total');
expect($prometheus)->toContain('component_id="test-component"');
});
it('includes timestamps in Prometheus format', function () {
$this->collector->recordRender('counter', 10.0);
$prometheus = $this->collector->exportPrometheus();
// Should contain timestamp (unix timestamp format)
expect($prometheus)->toMatch('/\d+\.\d+\s+\d+/');
});
it('handles labels with special characters', function () {
// Test with special characters: quote and newline
$this->collector->recordAction('form"test', "action\nname", 10.0);
$prometheus = $this->collector->exportPrometheus();
// Should escape special characters in Prometheus format
// Quote: " becomes \"
expect($prometheus)->toContain('form\\"test');
// Newline: \n becomes \n (literal backslash-n in output)
expect($prometheus)->toContain('action\nname');
});
it('exports empty string for empty collector', function () {
$prometheus = $this->collector->exportPrometheus();
expect($prometheus)->toContain('# HELP LiveComponents metrics');
expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram');
});
});
describe('Metric Key Building', function () {
it('creates unique keys for metrics with different labels', function () {
$this->collector->recordRender('component-a', 10.0, true);
$this->collector->recordRender('component-b', 20.0, true);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=true,component_id=component-a}');
expect($metrics)->toHaveKey('livecomponent_renders_total{cached=true,component_id=component-b}');
});
it('sorts labels alphabetically in metric keys', function () {
// Record action (labels: component_id, action, status)
$this->collector->recordAction('test', 'action1', 10.0, true);
$metrics = $this->collector->getMetrics();
// Key should have labels sorted: action, component_id, status
expect($metrics)->toHaveKey('livecomponent_actions_total{action=action1,component_id=test,status=success}');
});
});
describe('Metric Retrieval', function () {
it('retrieves metric by exact key', function () {
$this->collector->recordRender('my-component', 50.0);
$metric = $this->collector->getMetric('livecomponent_renders_total{cached=false,component_id=my-component}');
expect($metric)->not->toBeNull();
expect($metric->value)->toBe(1.0);
});
it('returns null for non-existent metric', function () {
$metric = $this->collector->getMetric('non_existent_metric');
expect($metric)->toBeNull();
});
it('retrieves all metrics', function () {
$this->collector->recordRender('comp1', 10.0);
$this->collector->recordRender('comp2', 20.0);
$this->collector->recordAction('comp1', 'action1', 30.0);
$metrics = $this->collector->getMetrics();
expect($metrics)->toBeArray();
expect(count($metrics))->toBeGreaterThan(0);
});
});
describe('Reset Functionality', function () {
it('clears all metrics on reset', function () {
// Add various metrics
$this->collector->recordRender('test', 10.0);
$this->collector->recordAction('test', 'action', 20.0);
$this->collector->recordCacheHit('test', true);
expect($this->collector->getMetrics())->not->toBeEmpty();
// Reset
$this->collector->reset();
expect($this->collector->getMetrics())->toBeEmpty();
});
it('allows recording new metrics after reset', function () {
$this->collector->recordRender('test', 10.0);
$this->collector->reset();
$this->collector->recordRender('test', 20.0);
$metrics = $this->collector->getMetrics();
expect($metrics)->toHaveCount(2); // renders_total + render_duration_ms
});
it('resets summary to zero values', function () {
$this->collector->recordRender('test', 10.0);
$this->collector->reset();
$summary = $this->collector->getSummary();
expect($summary['total_renders'])->toBe(0);
expect($summary['total_actions'])->toBe(0);
expect($summary['cache_hits'])->toBe(0);
});
});
describe('Counter Incrementing', function () {
it('increments counter metric on repeated calls', function () {
$this->collector->recordRender('counter-test', 10.0);
$this->collector->recordRender('counter-test', 15.0);
$this->collector->recordRender('counter-test', 20.0);
$metrics = $this->collector->getMetrics();
$counterMetric = $metrics['livecomponent_renders_total{cached=false,component_id=counter-test}'];
expect($counterMetric->value)->toBe(3.0);
});
it('increments by custom amount in batch operations', function () {
// Batch with 5 successes
$this->collector->recordBatch(5, 100.0, 5, 0);
$metrics = $this->collector->getMetrics();
expect($metrics['livecomponent_batch_success_total']->value)->toBe(5.0);
});
});
describe('Histogram Updates', function () {
it('updates histogram value on repeated observations', function () {
$this->collector->recordRender('histogram-test', 50.0);
$this->collector->recordRender('histogram-test', 100.0);
$metrics = $this->collector->getMetrics();
$histogramMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=histogram-test}'];
// Latest observation should be stored
expect($histogramMetric->value)->toBe(100.0);
});
});
describe('Metric Metadata', function () {
it('stores metric type correctly', function () {
$this->collector->recordRender('test', 10.0);
$metrics = $this->collector->getMetrics();
$counterMetric = $metrics['livecomponent_renders_total{cached=false,component_id=test}'];
expect($counterMetric->type)->toBe(MetricType::COUNTER);
$histogramMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=test}'];
expect($histogramMetric->type)->toBe(MetricType::HISTOGRAM);
});
it('stores labels correctly', function () {
$this->collector->recordAction('my-component', 'my-action', 50.0, true);
$metrics = $this->collector->getMetrics();
$actionMetric = $metrics['livecomponent_actions_total{action=my-action,component_id=my-component,status=success}'];
expect($actionMetric->labels)->toHaveKey('component_id');
expect($actionMetric->labels)->toHaveKey('action');
expect($actionMetric->labels)->toHaveKey('status');
expect($actionMetric->labels['component_id'])->toBe('my-component');
expect($actionMetric->labels['action'])->toBe('my-action');
expect($actionMetric->labels['status'])->toBe('success');
});
it('stores unit for duration histograms', function () {
$this->collector->recordRender('test', 10.0);
$metrics = $this->collector->getMetrics();
$histogramMetric = $metrics['livecomponent_render_duration_ms{cached=false,component_id=test}'];
expect($histogramMetric->unit)->toBe('ms');
});
it('stores timestamp for all metrics', function () {
$this->collector->recordRender('test', 10.0);
$metrics = $this->collector->getMetrics();
foreach ($metrics as $metric) {
expect($metric->timestamp)->not->toBeNull();
}
});
});
});

View File

@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Observability\ComponentMetricsCollector;
use App\Framework\Core\ValueObjects\Timestamp;
describe('ComponentMetricsCollector - Prometheus Export Validation', function () {
beforeEach(function () {
$this->collector = new ComponentMetricsCollector();
});
describe('Prometheus Format Validation', function () {
it('exports valid prometheus format with header', function () {
$this->collector->recordRender('test-component', 50.5);
$prometheus = $this->collector->exportPrometheus();
expect($prometheus)->toContain('# HELP LiveComponents metrics');
expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram');
});
it('exports metrics with proper line format', function () {
$this->collector->recordRender('test-component', 50.5);
$prometheus = $this->collector->exportPrometheus();
$lines = explode("\n", trim($prometheus));
// Find metric line (skip header comments)
$metricLines = array_filter($lines, fn($line) => !str_starts_with($line, '#') && !empty($line));
foreach ($metricLines as $line) {
// Format: metric_name{labels} value timestamp
expect($line)->toMatch('/^[a-z_]+\{.*\}\s+[\d.]+\s+\d+$/');
}
});
it('exports counter metrics correctly', function () {
$this->collector->recordRender('counter-test', 10.0);
$prometheus = $this->collector->exportPrometheus();
// Counter should be in format: livecomponent_renders_total{...} 1.00 timestamp
expect($prometheus)->toContain('livecomponent_renders_total');
expect($prometheus)->toMatch('/livecomponent_renders_total\{.*\}\s+1\.00\s+\d+/');
});
it('exports histogram metrics correctly', function () {
$this->collector->recordRender('histogram-test', 45.75);
$prometheus = $this->collector->exportPrometheus();
// Histogram should be in format: livecomponent_render_duration_ms{...} 45.75 timestamp
expect($prometheus)->toContain('livecomponent_render_duration_ms');
expect($prometheus)->toMatch('/livecomponent_render_duration_ms\{.*\}\s+45\.75\s+\d+/');
});
});
describe('Label Formatting', function () {
it('formats labels with alphabetical sorting', function () {
$this->collector->recordAction('component-1', 'submit', 100.0);
$prometheus = $this->collector->exportPrometheus();
// Labels should be sorted: action, component_id, status
expect($prometheus)->toContain('action="submit"');
expect($prometheus)->toContain('component_id="component-1"');
expect($prometheus)->toContain('status="success"');
// Verify alphabetical order in label string
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => str_contains($line, 'livecomponent_actions_total')
);
foreach ($metricLines as $line) {
// Extract label section between {}
preg_match('/\{(.*?)\}/', $line, $matches);
if (isset($matches[1])) {
$labels = explode(',', $matches[1]);
$labelKeys = array_map(fn($label) => explode('=', $label)[0], $labels);
// Verify alphabetical order
$sortedKeys = $labelKeys;
sort($sortedKeys);
expect($labelKeys)->toBe($sortedKeys);
}
}
});
it('escapes special characters in label values', function () {
// Component with special characters: quotes, backslashes, newlines
$this->collector->recordRender('test"component', 50.0);
$this->collector->recordAction('comp\\path', "action\nname", 10.0); // Use double quotes for actual newline
$prometheus = $this->collector->exportPrometheus();
// Quotes should be escaped as \"
expect($prometheus)->toContain('test\\"component');
// Backslashes should be escaped as \\
expect($prometheus)->toContain('comp\\\\path');
// Newlines should be escaped as \n in Prometheus format
// The actual newline character (\n) should appear as backslash-n in the output
expect($prometheus)->toMatch('/action\\\\nname/'); // Regex to find literal \n
});
it('handles empty labels correctly', function () {
// recordHistogram with no labels (internal method tested via batch)
$this->collector->recordBatch(10, 500.0, 8, 2);
$prometheus = $this->collector->exportPrometheus();
// Some metrics should have empty labels {}
expect($prometheus)->toContain('livecomponent_batch_size');
expect($prometheus)->toContain('livecomponent_batch_duration_ms');
// These metrics have labels
expect($prometheus)->toContain('livecomponent_batch_operations_total{status="executed"}');
});
it('formats boolean labels as strings', function () {
$this->collector->recordRender('component-1', 50.0, cached: false);
$this->collector->recordRender('component-2', 25.0, cached: true);
$prometheus = $this->collector->exportPrometheus();
// Boolean should be formatted as "true" or "false" strings
expect($prometheus)->toContain('cached="false"');
expect($prometheus)->toContain('cached="true"');
expect($prometheus)->not->toContain('cached=true');
expect($prometheus)->not->toContain('cached=false');
});
});
describe('Value Formatting', function () {
it('formats float values with 2 decimal places', function () {
$this->collector->recordRender('test', 123.456);
$prometheus = $this->collector->exportPrometheus();
// Values should have .2f format
expect($prometheus)->toContain('123.46'); // Rounded to 2 decimals
});
it('formats integer values with .00', function () {
$this->collector->recordAction('test', 'click', 100.0); // Creates counter = 1
$prometheus = $this->collector->exportPrometheus();
// Counter value should be 1.00
expect($prometheus)->toMatch('/livecomponent_actions_total\{.*\}\s+1\.00/');
});
it('handles zero values correctly', function () {
// Create metric and then subtract to get 0 (through reset and re-create)
$this->collector->recordRender('test', 0.0);
$prometheus = $this->collector->exportPrometheus();
expect($prometheus)->toContain('0.00');
});
});
describe('Timestamp Formatting', function () {
it('includes unix timestamp for each metric', function () {
$beforeTime = time();
$this->collector->recordRender('test', 50.0);
$afterTime = time();
$prometheus = $this->collector->exportPrometheus();
// Extract timestamp from metric line
preg_match('/livecomponent_renders_total\{.*\}\s+[\d.]+\s+(\d+)/', $prometheus, $matches);
expect($matches)->toHaveKey(1);
$timestamp = (int) $matches[1];
// Timestamp should be within test execution time range
expect($timestamp)->toBeGreaterThanOrEqual($beforeTime);
expect($timestamp)->toBeLessThanOrEqual($afterTime + 1); // +1 for race condition
});
it('uses metric timestamp if available', function () {
$this->collector->recordRender('test', 50.0);
$prometheus = $this->collector->exportPrometheus();
// Timestamp should be present in all metric lines
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => !str_starts_with($line, '#') && !empty($line)
);
foreach ($metricLines as $line) {
expect($line)->toMatch('/\d+$/'); // Line ends with timestamp
}
});
});
describe('Multiple Metrics Export', function () {
it('exports multiple metrics with different labels', function () {
$this->collector->recordRender('component-1', 50.0, cached: false);
$this->collector->recordRender('component-2', 30.0, cached: true);
$this->collector->recordAction('component-1', 'submit', 100.0);
$this->collector->recordAction('component-2', 'click', 75.0);
$prometheus = $this->collector->exportPrometheus();
// Should have multiple metric lines
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => !str_starts_with($line, '#') && !empty($line)
);
expect(count($metricLines))->toBeGreaterThan(4); // At least 2 renders + 2 actions (each creates 2 metrics)
});
it('exports metrics in consistent format', function () {
$this->collector->recordRender('comp-1', 10.0);
$this->collector->recordAction('comp-2', 'test', 20.0);
$this->collector->recordCacheHit('comp-3', true);
$this->collector->recordEventDispatched('comp-4', 'updated');
$prometheus = $this->collector->exportPrometheus();
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => !str_starts_with($line, '#') && !empty($line)
);
// All lines should match Prometheus format
foreach ($metricLines as $line) {
// Format: metric_name{labels} value timestamp
expect($line)->toMatch('/^[a-z_]+(\{.*?\})?\s+[\d.]+\s+\d+$/');
}
});
});
describe('Special Cases', function () {
it('handles empty metrics collection', function () {
$prometheus = $this->collector->exportPrometheus();
// Should still have headers even with no metrics
expect($prometheus)->toContain('# HELP LiveComponents metrics');
expect($prometheus)->toContain('# TYPE livecomponent_* counter/histogram');
// Should only have headers (2 lines + empty line)
$lines = explode("\n", $prometheus);
$metricLines = array_filter($lines, fn($line) => !str_starts_with($line, '#') && !empty($line));
expect(count($metricLines))->toBe(0);
});
it('handles metrics with complex label combinations', function () {
$this->collector->recordAction('cart-component', 'add-to-cart', 150.0, success: true);
$this->collector->recordAction('cart-component', 'add-to-cart', 200.0, success: false);
$this->collector->recordAction('checkout-component', 'submit-payment', 300.0, success: true);
$prometheus = $this->collector->exportPrometheus();
// Should have multiple metrics with different label combinations
expect($prometheus)->toContain('component_id="cart-component"');
expect($prometheus)->toContain('action="add-to-cart"');
expect($prometheus)->toContain('status="success"');
expect($prometheus)->toContain('status="error"');
expect($prometheus)->toContain('component_id="checkout-component"');
expect($prometheus)->toContain('action="submit-payment"');
});
it('handles incremental counter updates', function () {
// Record same action 3 times
$this->collector->recordAction('test', 'click', 10.0);
$this->collector->recordAction('test', 'click', 15.0);
$this->collector->recordAction('test', 'click', 20.0);
$prometheus = $this->collector->exportPrometheus();
// Counter should be incremented to 3
preg_match('/livecomponent_actions_total\{.*\}\s+([\d.]+)/', $prometheus, $matches);
expect($matches)->toHaveKey(1);
expect((float) $matches[1])->toBe(3.0);
});
});
describe('Integration with Metric Types', function () {
it('exports all LiveComponent metric types', function () {
// Render metrics
$this->collector->recordRender('comp', 50.0);
// Action metrics
$this->collector->recordAction('comp', 'submit', 100.0);
// Cache metrics
$this->collector->recordCacheHit('comp', true);
// Event metrics
$this->collector->recordEventDispatched('comp', 'updated');
$this->collector->recordEventReceived('comp', 'refresh');
// Hydration metrics
$this->collector->recordHydration('comp', 30.0);
// Batch metrics
$this->collector->recordBatch(10, 500.0, 8, 2);
// Fragment metrics
$this->collector->recordFragmentUpdate('comp', 5, 75.0);
// Upload metrics
$this->collector->recordUploadChunk('session-1', 1, 200.0);
$this->collector->recordUploadComplete('session-1', 2000.0, 10);
$prometheus = $this->collector->exportPrometheus();
// Verify all metric types are present
expect($prometheus)->toContain('livecomponent_renders_total');
expect($prometheus)->toContain('livecomponent_render_duration_ms');
expect($prometheus)->toContain('livecomponent_actions_total');
expect($prometheus)->toContain('livecomponent_action_duration_ms');
expect($prometheus)->toContain('livecomponent_cache_hits_total');
expect($prometheus)->toContain('livecomponent_events_dispatched_total');
expect($prometheus)->toContain('livecomponent_events_received_total');
expect($prometheus)->toContain('livecomponent_hydration_duration_ms');
expect($prometheus)->toContain('livecomponent_batch_operations_total');
expect($prometheus)->toContain('livecomponent_fragment_updates_total');
expect($prometheus)->toContain('livecomponent_upload_chunks_total');
expect($prometheus)->toContain('livecomponent_uploads_completed_total');
});
});
describe('Prometheus Format Compliance', function () {
it('follows Prometheus metric naming conventions', function () {
$this->collector->recordRender('test', 50.0);
$this->collector->recordAction('test', 'click', 100.0);
$prometheus = $this->collector->exportPrometheus();
// Metric names should be lowercase with underscores
// Should end with _total for counters or _ms for duration histograms
$metricLines = array_filter(
explode("\n", $prometheus),
fn($line) => !str_starts_with($line, '#') && !empty($line)
);
foreach ($metricLines as $line) {
preg_match('/^([a-z_]+)/', $line, $matches);
$metricName = $matches[1] ?? '';
expect($metricName)->toMatch('/^[a-z][a-z0-9_]*$/');
// Counters should end with _total
if (str_contains($line, 'renders_total') || str_contains($line, 'actions_total')) {
expect($metricName)->toMatch('/_total$/');
}
// Duration metrics should end with _ms
if (str_contains($line, 'duration')) {
expect($metricName)->toMatch('/_ms$/');
}
}
});
it('uses valid label key naming', function () {
$this->collector->recordAction('test', 'submit', 100.0);
$prometheus = $this->collector->exportPrometheus();
// Extract label keys
preg_match_all('/([a-z_]+)="[^"]*"/', $prometheus, $matches);
$labelKeys = $matches[1] ?? [];
// Label keys should be lowercase with underscores
foreach ($labelKeys as $key) {
expect($key)->toMatch('/^[a-z][a-z0-9_]*$/');
}
});
it('produces parseable prometheus output', function () {
$this->collector->recordRender('test', 50.0);
$this->collector->recordAction('test', 'click', 100.0);
$prometheus = $this->collector->exportPrometheus();
// Basic structure validation
$lines = explode("\n", $prometheus);
foreach ($lines as $line) {
if (empty($line)) {
continue;
}
// Either comment or metric line
if (str_starts_with($line, '#')) {
// Comment line - should start with # HELP or # TYPE
expect($line)->toMatch('/^#\s+(HELP|TYPE)\s+/');
} else {
// Metric line - should have metric name, optional labels, value, timestamp
expect($line)->toMatch('/^[a-z_]+(\{.*?\})?\s+[\d.]+\s+\d+$/');
}
}
});
});
});

View File

@@ -0,0 +1,712 @@
/**
* @jest-environment jsdom
*/
import { LiveComponentDevTools } from '../../../../../resources/js/modules/LiveComponentDevTools.js';
import { Core } from '../../../../../resources/js/modules/core.js';
describe('LiveComponentDevTools', () => {
let devTools;
let mockLocalStorage;
beforeEach(() => {
// Reset DOM
document.body.innerHTML = '';
// Mock localStorage
mockLocalStorage = {};
global.localStorage = {
getItem: jest.fn((key) => mockLocalStorage[key] || null),
setItem: jest.fn((key, value) => { mockLocalStorage[key] = value; }),
removeItem: jest.fn((key) => { delete mockLocalStorage[key]; }),
clear: jest.fn(() => { mockLocalStorage = {}; })
};
// Set development environment
document.documentElement.dataset.env = 'development';
// Mock Core.emit
Core.emit = jest.fn();
Core.on = jest.fn();
// Mock performance.memory (Chrome-specific)
if (!window.performance.memory) {
window.performance.memory = {
usedJSHeapSize: 10000000,
totalJSHeapSize: 20000000,
jsHeapSizeLimit: 100000000
};
}
// Mock window.LiveComponent
window.LiveComponent = {
enableDevTools: jest.fn()
};
});
afterEach(() => {
// Cleanup
if (devTools && devTools.overlay) {
devTools.overlay.remove();
}
devTools = null;
});
describe('Initialization', () => {
it('initializes in development mode', () => {
document.documentElement.dataset.env = 'development';
devTools = new LiveComponentDevTools();
expect(devTools.isEnabled).toBe(true);
expect(devTools.overlay).not.toBeNull();
expect(document.getElementById('livecomponent-devtools')).not.toBeNull();
});
it('does not initialize in production mode by default', () => {
document.documentElement.dataset.env = 'production';
devTools = new LiveComponentDevTools();
expect(devTools.isEnabled).toBe(false);
expect(devTools.overlay).toBeNull();
});
it('initializes in production with explicit localStorage flag', () => {
document.documentElement.dataset.env = 'production';
mockLocalStorage['livecomponent_devtools'] = 'true';
devTools = new LiveComponentDevTools();
expect(devTools.isEnabled).toBe(true);
expect(devTools.overlay).not.toBeNull();
});
it('creates all required UI elements', () => {
devTools = new LiveComponentDevTools();
const overlay = document.getElementById('livecomponent-devtools');
expect(overlay).not.toBeNull();
// Check header
expect(overlay.querySelector('.lc-devtools__header')).not.toBeNull();
expect(overlay.querySelector('.lc-devtools__title')).not.toBeNull();
// Check tabs
expect(overlay.querySelector('[data-tab="components"]')).not.toBeNull();
expect(overlay.querySelector('[data-tab="actions"]')).not.toBeNull();
expect(overlay.querySelector('[data-tab="events"]')).not.toBeNull();
expect(overlay.querySelector('[data-tab="performance"]')).not.toBeNull();
expect(overlay.querySelector('[data-tab="network"]')).not.toBeNull();
// Check panes
expect(overlay.querySelector('[data-pane="components"]')).not.toBeNull();
expect(overlay.querySelector('[data-pane="actions"]')).not.toBeNull();
expect(overlay.querySelector('[data-pane="events"]')).not.toBeNull();
expect(overlay.querySelector('[data-pane="performance"]')).not.toBeNull();
expect(overlay.querySelector('[data-pane="network"]')).not.toBeNull();
// Check action buttons
expect(overlay.querySelector('[data-action="minimize"]')).not.toBeNull();
expect(overlay.querySelector('[data-action="close"]')).not.toBeNull();
expect(overlay.querySelector('[data-action="toggle-badges"]')).not.toBeNull();
});
it('connects to LiveComponent manager', (done) => {
devTools = new LiveComponentDevTools();
setTimeout(() => {
expect(window.LiveComponent.enableDevTools).toHaveBeenCalledWith(devTools);
done();
}, 150);
});
});
describe('Component Discovery', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('discovers components via data-component-id attribute', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter">
<span>Count: 0</span>
</div>
<div data-component-id="user-stats:123" data-component-name="UserStats">
<p>Stats</p>
</div>
`;
devTools.refreshComponents();
expect(devTools.components.size).toBe(2);
expect(devTools.components.has('counter:demo')).toBe(true);
expect(devTools.components.has('user-stats:123')).toBe(true);
const counter = devTools.components.get('counter:demo');
expect(counter.name).toBe('Counter');
expect(counter.id).toBe('counter:demo');
});
it('extracts component props from data attributes', () => {
document.body.innerHTML = `
<div
data-component-id="product:5"
data-component-name="Product"
data-product-id="5"
data-category="electronics"
>
Product
</div>
`;
devTools.refreshComponents();
const product = devTools.components.get('product:5');
expect(product.props.productId).toBe('5');
expect(product.props.category).toBe('electronics');
expect(product.props.componentId).toBeUndefined(); // Excluded
expect(product.props.componentName).toBeUndefined(); // Excluded
});
it('handles components without data-component-name', () => {
document.body.innerHTML = `
<div data-component-id="anonymous:1">Content</div>
`;
devTools.refreshComponents();
const component = devTools.components.get('anonymous:1');
expect(component.name).toBe('Unknown');
});
});
describe('Action Logging', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('logs action executions', () => {
const startTime = 100;
const endTime = 150;
devTools.logAction('counter:demo', 'increment', { amount: 5 }, startTime, endTime, true);
expect(devTools.actionLog.length).toBe(1);
const logEntry = devTools.actionLog[0];
expect(logEntry.componentId).toBe('counter:demo');
expect(logEntry.actionName).toBe('increment');
expect(logEntry.params).toEqual({ amount: 5 });
expect(logEntry.duration).toBe(50);
expect(logEntry.success).toBe(true);
expect(logEntry.error).toBeNull();
});
it('logs action failures with error messages', () => {
devTools.logAction('form:contact', 'submit', {}, 100, 200, false, 'Validation failed');
const logEntry = devTools.actionLog[0];
expect(logEntry.success).toBe(false);
expect(logEntry.error).toBe('Validation failed');
});
it('maintains max 100 action log entries', () => {
// Add 110 actions
for (let i = 0; i < 110; i++) {
devTools.logAction(`component:${i}`, 'action', {}, 0, 10, true);
}
expect(devTools.actionLog.length).toBe(100);
// Most recent should be at index 0
expect(devTools.actionLog[0].componentId).toBe('component:109');
});
it('records action execution for performance profiling when recording', () => {
devTools.isRecording = true;
devTools.logAction('counter:demo', 'increment', {}, 100, 150, true);
expect(devTools.performanceRecording.length).toBe(1);
expect(devTools.performanceRecording[0].type).toBe('action');
expect(devTools.performanceRecording[0].duration).toBe(50);
});
it('does not record performance when not recording', () => {
devTools.isRecording = false;
devTools.logAction('counter:demo', 'increment', {}, 100, 150, true);
expect(devTools.performanceRecording.length).toBe(0);
});
});
describe('Event Logging', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('logs events with source tracking', () => {
devTools.logEvent('user:updated', { userId: 123 }, 'client');
expect(devTools.eventLog.length).toBe(1);
const logEntry = devTools.eventLog[0];
expect(logEntry.eventName).toBe('user:updated');
expect(logEntry.data).toEqual({ userId: 123 });
expect(logEntry.source).toBe('client');
});
it('defaults source to client', () => {
devTools.logEvent('notification:received', { message: 'Hello' });
expect(devTools.eventLog[0].source).toBe('client');
});
it('maintains max 100 event log entries', () => {
// Add 110 events
for (let i = 0; i < 110; i++) {
devTools.logEvent(`event:${i}`, {}, 'client');
}
expect(devTools.eventLog.length).toBe(100);
// Most recent should be at index 0
expect(devTools.eventLog[0].eventName).toBe('event:109');
});
it('intercepts Core.emit for automatic event logging', () => {
const originalEmit = Core.emit;
devTools.interceptCoreEmit();
Core.emit('test:event', { data: 'test' });
expect(devTools.eventLog.length).toBe(1);
expect(devTools.eventLog[0].eventName).toBe('test:event');
expect(devTools.eventLog[0].data).toEqual({ data: 'test' });
expect(devTools.eventLog[0].source).toBe('client');
});
});
describe('Network Monitoring', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
// Mock original fetch
global.originalFetch = global.fetch;
global.fetch = jest.fn();
});
afterEach(() => {
// Restore original fetch
global.fetch = global.originalFetch;
});
it('intercepts fetch requests', async () => {
global.fetch.mockResolvedValue({
status: 200,
ok: true
});
devTools.monitorNetworkRequests();
await window.fetch('/api/users');
expect(devTools.networkLog.length).toBe(1);
const logEntry = devTools.networkLog[0];
expect(logEntry.method).toBe('GET');
expect(logEntry.url).toBe('/api/users');
expect(logEntry.status).toBe(200);
expect(logEntry.duration).toBeGreaterThanOrEqual(0);
});
it('logs POST requests with correct method', async () => {
global.fetch.mockResolvedValue({
status: 201,
ok: true
});
devTools.monitorNetworkRequests();
await window.fetch('/api/users', { method: 'POST' });
expect(devTools.networkLog[0].method).toBe('POST');
expect(devTools.networkLog[0].status).toBe(201);
});
it('maintains max 50 network log entries', async () => {
global.fetch.mockResolvedValue({ status: 200, ok: true });
devTools.monitorNetworkRequests();
// Make 60 requests
for (let i = 0; i < 60; i++) {
await window.fetch(`/api/request/${i}`);
}
expect(devTools.networkLog.length).toBe(50);
});
it('logs failed requests', async () => {
global.fetch.mockRejectedValue(new Error('Network error'));
devTools.monitorNetworkRequests();
try {
await window.fetch('/api/fail');
} catch (error) {
// Expected
}
expect(devTools.networkLog.length).toBe(1);
expect(devTools.networkLog[0].status).toBe('Error');
});
});
describe('Tab Switching', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
devTools.open();
});
it('switches to actions tab', () => {
devTools.switchTab('actions');
expect(devTools.activeTab).toBe('actions');
const actionsTab = devTools.overlay.querySelector('[data-tab="actions"]');
const actionsPane = devTools.overlay.querySelector('[data-pane="actions"]');
expect(actionsTab.classList.contains('lc-devtools__tab--active')).toBe(true);
expect(actionsPane.classList.contains('lc-devtools__pane--active')).toBe(true);
});
it('deactivates previous tab when switching', () => {
devTools.switchTab('events');
const componentsTab = devTools.overlay.querySelector('[data-tab="components"]');
const componentsPane = devTools.overlay.querySelector('[data-pane="components"]');
expect(componentsTab.classList.contains('lc-devtools__tab--active')).toBe(false);
expect(componentsPane.classList.contains('lc-devtools__pane--active')).toBe(false);
});
it('refreshes content when switching tabs', () => {
devTools.renderActionLog = jest.fn();
devTools.switchTab('actions');
expect(devTools.renderActionLog).toHaveBeenCalled();
});
});
describe('Open/Close/Minimize', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('opens DevTools and emits event', () => {
devTools.open();
expect(devTools.isOpen).toBe(true);
expect(devTools.overlay.style.display).toBe('block');
expect(Core.emit).toHaveBeenCalledWith('devtools:opened');
});
it('closes DevTools and emits event', () => {
devTools.open();
devTools.close();
expect(devTools.isOpen).toBe(false);
expect(devTools.overlay.style.display).toBe('none');
expect(Core.emit).toHaveBeenCalledWith('devtools:closed');
});
it('toggles DevTools visibility', () => {
expect(devTools.isOpen).toBe(false);
devTools.toggle();
expect(devTools.isOpen).toBe(true);
devTools.toggle();
expect(devTools.isOpen).toBe(false);
});
it('minimizes and unminimizes DevTools', () => {
devTools.open();
devTools.toggleMinimize();
expect(devTools.overlay.classList.contains('lc-devtools--minimized')).toBe(true);
devTools.toggleMinimize();
expect(devTools.overlay.classList.contains('lc-devtools--minimized')).toBe(false);
});
});
describe('Keyboard Shortcuts', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('toggles DevTools with Ctrl+Shift+D', () => {
const event = new KeyboardEvent('keydown', {
key: 'D',
ctrlKey: true,
shiftKey: true
});
document.dispatchEvent(event);
expect(devTools.isOpen).toBe(true);
});
it('toggles DevTools with Cmd+Shift+D on Mac', () => {
const event = new KeyboardEvent('keydown', {
key: 'D',
metaKey: true, // Cmd key on Mac
shiftKey: true
});
document.dispatchEvent(event);
expect(devTools.isOpen).toBe(true);
});
});
describe('DOM Badges', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('creates DOM badges for components', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter">
Counter
</div>
`;
devTools.updateDomBadges();
expect(devTools.domBadges.size).toBe(1);
expect(devTools.domBadges.has('counter:demo')).toBe(true);
const badge = document.querySelector('.lc-dom-badge[data-component-id="counter:demo"]');
expect(badge).not.toBeNull();
expect(badge.textContent).toContain('Counter');
});
it('updates badge activity counter', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
devTools.updateDomBadges();
devTools.updateBadgeActivity('counter:demo');
const badgeData = devTools.domBadges.get('counter:demo');
expect(badgeData.actionCount).toBe(1);
const actionsSpan = badgeData.badge.querySelector('.lc-badge-actions');
expect(actionsSpan.textContent).toBe('1 action');
});
it('pluralizes action count correctly', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
devTools.updateDomBadges();
devTools.updateBadgeActivity('counter:demo');
devTools.updateBadgeActivity('counter:demo');
const actionsSpan = devTools.domBadges.get('counter:demo').badge.querySelector('.lc-badge-actions');
expect(actionsSpan.textContent).toBe('2 actions');
});
it('toggles badge visibility', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
devTools.updateDomBadges();
const badgeData = devTools.domBadges.get('counter:demo');
expect(badgeData.badge.style.display).not.toBe('none');
devTools.toggleBadges();
expect(badgeData.badge.style.display).toBe('none');
devTools.toggleBadges();
expect(badgeData.badge.style.display).toBe('block');
});
it('cleans up badges for removed components', () => {
document.body.innerHTML = `
<div data-component-id="counter:demo" data-component-name="Counter"></div>
`;
devTools.updateDomBadges();
expect(devTools.domBadges.size).toBe(1);
// Remove component from DOM
document.body.innerHTML = '';
devTools.cleanupRemovedBadges();
expect(devTools.domBadges.size).toBe(0);
});
});
describe('Performance Profiling', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('starts performance recording', () => {
devTools.startPerformanceRecording();
expect(devTools.isRecording).toBe(true);
expect(devTools.performanceRecording).toEqual([]);
expect(devTools.memorySnapshots.length).toBeGreaterThan(0); // Initial snapshot
});
it('stops performance recording', () => {
devTools.startPerformanceRecording();
devTools.stopPerformanceRecording();
expect(devTools.isRecording).toBe(false);
});
it('records component renders', () => {
devTools.isRecording = true;
devTools.recordComponentRender('counter:demo', 25, 100, 125);
expect(devTools.performanceRecording.length).toBe(1);
expect(devTools.performanceRecording[0].type).toBe('render');
expect(devTools.performanceRecording[0].componentId).toBe('counter:demo');
expect(devTools.performanceRecording[0].duration).toBe(25);
});
it('does not record when not recording', () => {
devTools.isRecording = false;
devTools.recordComponentRender('counter:demo', 25, 100, 125);
expect(devTools.performanceRecording.length).toBe(0);
});
it('takes memory snapshots', () => {
devTools.takeMemorySnapshot();
expect(devTools.memorySnapshots.length).toBe(1);
const snapshot = devTools.memorySnapshots[0];
expect(snapshot.usedJSHeapSize).toBeDefined();
expect(snapshot.totalJSHeapSize).toBeDefined();
expect(snapshot.jsHeapSizeLimit).toBeDefined();
expect(snapshot.timestamp).toBeDefined();
});
it('maintains max 100 performance recordings', () => {
devTools.isRecording = true;
for (let i = 0; i < 110; i++) {
devTools.recordComponentRender(`component:${i}`, 10, 0, 10);
}
expect(devTools.performanceRecording.length).toBe(100);
});
it('maintains max 100 memory snapshots', () => {
for (let i = 0; i < 110; i++) {
devTools.takeMemorySnapshot();
}
expect(devTools.memorySnapshots.length).toBe(100);
});
});
describe('Clear Operations', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('clears action log', () => {
devTools.logAction('component:1', 'action', {}, 0, 10, true);
devTools.logAction('component:2', 'action', {}, 0, 10, true);
expect(devTools.actionLog.length).toBe(2);
devTools.clearActionLog();
expect(devTools.actionLog.length).toBe(0);
});
it('clears event log', () => {
devTools.logEvent('event:1', {});
devTools.logEvent('event:2', {});
expect(devTools.eventLog.length).toBe(2);
devTools.clearEventLog();
expect(devTools.eventLog.length).toBe(0);
});
it('clears network log', () => {
devTools.networkLog = [
{ timestamp: Date.now(), method: 'GET', url: '/api/users', status: 200, duration: 50 },
{ timestamp: Date.now(), method: 'POST', url: '/api/posts', status: 201, duration: 100 }
];
expect(devTools.networkLog.length).toBe(2);
devTools.clearNetworkLog();
expect(devTools.networkLog.length).toBe(0);
});
it('clears performance data', () => {
devTools.performanceRecording = [{ type: 'action', duration: 10 }];
devTools.componentRenderTimes.set('component:1', [10, 20]);
devTools.actionExecutionTimes.set('component:1:action', [5, 10]);
devTools.memorySnapshots = [{ timestamp: Date.now() }];
devTools.clearPerformanceData();
expect(devTools.performanceRecording.length).toBe(0);
expect(devTools.componentRenderTimes.size).toBe(0);
expect(devTools.actionExecutionTimes.size).toBe(0);
expect(devTools.memorySnapshots.length).toBe(0);
});
});
describe('Utility Methods', () => {
beforeEach(() => {
devTools = new LiveComponentDevTools();
});
it('formats bytes to human-readable string', () => {
expect(devTools.formatBytes(0)).toBe('0 B');
expect(devTools.formatBytes(1024)).toBe('1 KB');
expect(devTools.formatBytes(1048576)).toBe('1 MB');
expect(devTools.formatBytes(1073741824)).toBe('1 GB');
expect(devTools.formatBytes(1536)).toBe('1.5 KB');
});
it('checks if enabled in development mode', () => {
document.documentElement.dataset.env = 'development';
expect(devTools.checkIfEnabled()).toBe(true);
});
it('checks if enabled with localStorage override', () => {
document.documentElement.dataset.env = 'production';
mockLocalStorage['livecomponent_devtools'] = 'true';
expect(devTools.checkIfEnabled()).toBe(true);
});
it('is disabled in production without override', () => {
document.documentElement.dataset.env = 'production';
expect(devTools.checkIfEnabled()).toBe(false);
});
});
});

View File

@@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
use App\Framework\DateTime\SystemClock;
use App\Framework\DateTime\SystemHighResolutionClock;
use App\Framework\LiveComponents\Performance\ActionProfiler;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Performance\NestedPerformanceTracker;
/**
* Unit Tests for ActionProfiler
*
* Tests ActionProfiler's capabilities for profiling LiveComponent actions:
* - CSRF validation profiling
* - Authorization check profiling
* - Rate limit check profiling
* - Parameter binding profiling
* - Idempotency check profiling
* - Action metrics aggregation
* - Component-level summaries
* - System-wide performance reports
*/
describe('ActionProfiler', function () {
beforeEach(function () {
$this->tracker = new NestedPerformanceTracker(
new SystemClock(),
new SystemHighResolutionClock(),
new MemoryMonitor()
);
$this->profiler = new ActionProfiler($this->tracker);
$this->componentId = ComponentId::create('test-component', 'instance-1');
});
it('profiles CSRF validation', function () {
$this->profiler->profileCsrfValidation(
$this->componentId,
'testAction',
function () {
usleep(1000); // 1ms
return true;
}
);
$timeline = $this->tracker->generateTimeline();
expect($timeline)->not->toBeEmpty();
$csrfEvents = array_filter($timeline, fn($e) => str_contains($e['name'], 'csrf'));
expect($csrfEvents)->not->toBeEmpty();
$event = array_values($csrfEvents)[0];
expect($event['duration_ms'])->toBeGreaterThan(0.5);
expect($event)->toHaveKey('context');
expect($event['context'])->toHaveKey('component');
expect($event['context']['component'])->toBe('test-component');
});
it('profiles authorization check', function () {
$this->profiler->profileAuthorizationCheck(
$this->componentId,
'testAction',
function () {
usleep(500); // 0.5ms
return true;
}
);
$timeline = $this->tracker->generateTimeline();
$authEvents = array_filter($timeline, fn($e) => str_contains($e['name'], 'authorization'));
expect($authEvents)->not->toBeEmpty();
$event = array_values($authEvents)[0];
expect($event['context'])->toHaveKey('action');
expect($event['context']['action'])->toBe('testAction');
});
it('profiles rate limit check', function () {
$this->profiler->profileRateLimitCheck(
$this->componentId,
'testAction',
function () {
usleep(300); // 0.3ms
return true;
}
);
$timeline = $this->tracker->generateTimeline();
$rateLimitEvents = array_filter($timeline, fn($e) => str_contains($e['name'], 'rateLimit'));
expect($rateLimitEvents)->not->toBeEmpty();
});
it('profiles parameter binding', function () {
$this->profiler->profileParameterBinding(
$this->componentId,
'testAction',
function () {
usleep(2000); // 2ms - reflection overhead
return ['param1' => 'value1'];
}
);
$timeline = $this->tracker->generateTimeline();
$bindingEvents = array_filter($timeline, fn($e) => str_contains($e['name'], 'binding'));
expect($bindingEvents)->not->toBeEmpty();
$event = array_values($bindingEvents)[0];
expect($event['duration_ms'])->toBeGreaterThan(1);
});
it('profiles idempotency check', function () {
$this->profiler->profileIdempotencyCheck(
$this->componentId,
'testAction',
function () {
usleep(400); // 0.4ms - cache lookup
return null; // No cached result
}
);
$timeline = $this->tracker->generateTimeline();
$idempotencyEvents = array_filter($timeline, fn($e) => str_contains($e['name'], 'idempotency'));
expect($idempotencyEvents)->not->toBeEmpty();
});
it('aggregates action metrics across multiple executions', function () {
$component1 = ComponentId::create('counter', 'inst-1');
// Execute multiple actions WITHOUT reset to accumulate metrics
for ($i = 0; $i < 3; $i++) {
$this->profiler->profileCsrfValidation($component1, 'increment', fn() => usleep(100));
$this->profiler->profileAuthorizationCheck($component1, 'increment', fn() => usleep(50));
}
// Get metrics for specific action
$counterMetrics = $this->profiler->getActionMetrics('counter', 'increment');
expect($counterMetrics)->toHaveKey('component');
expect($counterMetrics)->toHaveKey('action');
expect($counterMetrics)->toHaveKey('phase_timings');
expect($counterMetrics['component'])->toBe('counter');
expect($counterMetrics['action'])->toBe('increment');
// Verify we tracked multiple phases
expect($counterMetrics['phase_timings'])->not->toBeEmpty();
});
it('provides component-level performance summary', function () {
$component = ComponentId::create('user-profile', 'user-123');
// Multiple actions on same component WITHOUT reset
$this->profiler->profileCsrfValidation($component, 'update', fn() => usleep(100));
$this->profiler->profileParameterBinding($component, 'update', fn() => usleep(200));
$this->profiler->profileCsrfValidation($component, 'delete', fn() => usleep(100));
$this->profiler->profileAuthorizationCheck($component, 'delete', fn() => usleep(150));
$summary = $this->profiler->getComponentSummary('user-profile');
expect($summary)->toHaveKey('component');
expect($summary)->toHaveKey('total_operations');
expect($summary)->toHaveKey('total_time_ms');
expect($summary)->toHaveKey('operations');
expect($summary['component'])->toBe('user-profile');
expect($summary['total_operations'])->toBeGreaterThan(0);
expect($summary['operations'])->toBeArray();
});
it('generates system-wide performance report', function () {
$comp1 = ComponentId::create('component-a', 'a-1');
$comp2 = ComponentId::create('component-b', 'b-1');
$comp3 = ComponentId::create('component-c', 'c-1');
// Simulate multiple components and actions WITHOUT reset
$this->profiler->profileCsrfValidation($comp1, 'action1', fn() => usleep(100));
$this->profiler->profileAuthorizationCheck($comp2, 'action2', fn() => usleep(150));
$this->profiler->profileRateLimitCheck($comp3, 'action3', fn() => usleep(75));
$report = $this->profiler->generatePerformanceReport();
expect($report)->toHaveKey('generated_at');
expect($report)->toHaveKey('total_components');
expect($report)->toHaveKey('total_operations');
expect($report)->toHaveKey('components');
expect($report['total_components'])->toBe(3);
expect($report['components'])->toHaveKey('component-a');
expect($report['components'])->toHaveKey('component-b');
expect($report['components'])->toHaveKey('component-c');
});
it('tracks execution times for action phases', function () {
$component = ComponentId::create('fast-component', 'fast-1');
// Execute action multiple times
for ($i = 0; $i < 3; $i++) {
$this->profiler->profileCsrfValidation($component, 'quickAction', fn() => usleep(100));
}
$metrics = $this->profiler->getActionMetrics('fast-component', 'quickAction');
expect($metrics)->toHaveKey('phase_timings');
expect($metrics['phase_timings'])->not->toBeEmpty();
// Verify phase timing structure
$firstPhase = array_values($metrics['phase_timings'])[0];
expect($firstPhase)->toHaveKey('count');
expect($firstPhase)->toHaveKey('total_ms');
expect($firstPhase)->toHaveKey('avg_ms');
expect($firstPhase['count'])->toBe(3);
});
it('calculates average execution time for phases', function () {
$component = ComponentId::create('slow-component', 'slow-1');
// Execute multiple times
for ($i = 0; $i < 3; $i++) {
$this->profiler->profileParameterBinding($component, 'complexAction', fn() => usleep(1000));
}
$metrics = $this->profiler->getActionMetrics('slow-component', 'complexAction');
expect($metrics)->toHaveKey('phase_timings');
expect($metrics['phase_timings'])->not->toBeEmpty();
// Verify average is calculated correctly
$firstPhase = array_values($metrics['phase_timings'])[0];
expect($firstPhase['avg_ms'])->toBeGreaterThan(0);
expect($firstPhase['avg_ms'])->toBe($firstPhase['total_ms'] / $firstPhase['count']);
});
it('tracks total execution count per phase', function () {
$component = ComponentId::create('counted-component', 'count-1');
// Execute 5 times WITHOUT reset
for ($i = 0; $i < 5; $i++) {
$this->profiler->profileCsrfValidation($component, 'countedAction', fn() => usleep(100));
}
$metrics = $this->profiler->getActionMetrics('counted-component', 'countedAction');
expect($metrics)->toHaveKey('phase_timings');
expect($metrics['phase_timings'])->not->toBeEmpty();
// Verify count tracking
$firstPhase = array_values($metrics['phase_timings'])[0];
expect($firstPhase['count'])->toBe(5);
});
it('handles components with no profiled actions gracefully', function () {
$summary = $this->profiler->getComponentSummary('non-existent-component');
expect($summary['component'])->toBe('non-existent-component');
expect($summary['total_operations'])->toBe(0);
expect($summary['total_time_ms'])->toBe(0);
expect($summary['operations'])->toBe([]);
});
it('handles actions with no profiled phases gracefully', function () {
$metrics = $this->profiler->getActionMetrics('non-existent', 'action');
expect($metrics['component'])->toBe('non-existent');
expect($metrics['action'])->toBe('action');
expect($metrics['executions'])->toBe(0);
expect($metrics['metrics'])->toBe([]);
});
it('measures overhead of profiling system', function () {
$component = ComponentId::create('overhead-test', 'ov-1');
// Measure baseline (no profiling) - execute noop lambda
$baselineStart = microtime(true);
for ($i = 0; $i < 100; $i++) {
$noop = function () {}; // Minimal work
$noop();
}
$baselineDuration = (microtime(true) - $baselineStart) * 1000;
// Measure with profiling
$this->tracker->reset();
$profiledStart = microtime(true);
for ($i = 0; $i < 100; $i++) {
$this->profiler->profileCsrfValidation($component, 'noop', fn() => null);
}
$profiledDuration = (microtime(true) - $profiledStart) * 1000;
// Overhead should be reasonable (<5ms for 100 operations)
$overhead = $profiledDuration - $baselineDuration;
expect($overhead)->toBeLessThan(10); // Maximum 10ms overhead for 100 operations
});
it('includes context data in all profiled operations', function () {
$component = ComponentId::create('context-test', 'ctx-1');
$this->profiler->profileCsrfValidation($component, 'contextAction', fn() => usleep(100));
$timeline = $this->tracker->generateTimeline();
foreach ($timeline as $event) {
expect($event)->toHaveKey('context');
expect($event['context'])->toHaveKey('component');
expect($event['context'])->toHaveKey('action');
}
});
});

View File

@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Profiling\LiveComponentProfiler;
use App\Framework\LiveComponents\Profiling\ProfileSession;
use App\Framework\LiveComponents\Profiling\ProfileTimeline;
use App\Framework\LiveComponents\Profiling\ValueObjects\ProfileResult;
use App\Framework\LiveComponents\Profiling\ValueObjects\ProfileSessionId;
use App\Framework\LiveComponents\Profiling\ValueObjects\ProfilePhase;
use App\Framework\LiveComponents\Profiling\ValueObjects\MemorySnapshot;
use App\Framework\LiveComponents\Observability\ComponentMetricsCollector;
use App\Framework\Telemetry\UnifiedTelemetryService;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Timestamp;
use Tests\Unit\Framework\LiveComponents\Profiling\SimpleTelemetryService;
describe('LiveComponentProfiler', function () {
beforeEach(function () {
// Use simple test implementations
$randomGenerator = new \App\Framework\Random\SecureRandomGenerator();
$this->telemetryService = new SimpleTelemetryService($randomGenerator);
$this->metricsCollector = new ComponentMetricsCollector();
$this->memoryMonitor = new MemoryMonitor();
$this->profiler = new LiveComponentProfiler(
$this->telemetryService,
$this->metricsCollector,
$this->memoryMonitor
);
});
it('starts profiling session', function () {
$session = $this->profiler->startSession('UserCard');
expect($session)->toBeInstanceOf(ProfileSession::class);
expect($session->componentId)->toBe('UserCard');
expect($session->sessionId)->toBeInstanceOf(ProfileSessionId::class);
expect($session->startTime)->toBeInstanceOf(Timestamp::class);
expect($session->startMemory)->toBeInstanceOf(Byte::class);
expect($session->operation)->toBeInstanceOf(\App\Framework\Telemetry\OperationHandle::class);
});
it('profiles resolve phase', function () {
$session = $this->profiler->startSession('UserCard');
$result = $this->profiler->profileResolve($session, function () {
return ['resolved' => true];
});
expect($result)->toBe(['resolved' => true]);
expect($session->getPhases())->toHaveCount(1);
$resolvePhase = $session->getPhase('resolve');
expect($resolvePhase)->toBeInstanceOf(ProfilePhase::class);
expect($resolvePhase->name)->toBe('resolve');
expect($resolvePhase->isSuccessful())->toBeTrue();
});
it('profiles render phase with cache flag', function () {
$session = $this->profiler->startSession('UserCard');
$html = $this->profiler->profileRender($session, function () {
return '<div>User Card</div>';
}, cached: true);
expect($html)->toBe('<div>User Card</div>');
expect($session->getPhases())->toHaveCount(1);
$renderPhase = $session->getPhase('render');
expect($renderPhase)->toBeInstanceOf(ProfilePhase::class);
expect($renderPhase->attributes['cached'])->toBeTrue();
expect($renderPhase->isSuccessful())->toBeTrue();
});
it('profiles action execution', function () {
$session = $this->profiler->startSession('UserCard');
$result = $this->profiler->profileAction($session, 'submit', function () {
return ['success' => true];
});
expect($result)->toBe(['success' => true]);
expect($session->getPhases())->toHaveCount(1);
$actionPhase = $session->getPhase('action.submit');
expect($actionPhase)->toBeInstanceOf(ProfilePhase::class);
expect($actionPhase->name)->toBe('action.submit');
expect($actionPhase->isSuccessful())->toBeTrue();
});
it('handles action errors', function () {
$session = $this->profiler->startSession('UserCard');
expect(fn() => $this->profiler->profileAction($session, 'submit', function () {
throw new \RuntimeException('Action failed');
}))->toThrow(\RuntimeException::class);
$actionPhase = $session->getPhase('action.submit');
expect($actionPhase)->toBeInstanceOf(ProfilePhase::class);
expect($actionPhase->isSuccessful())->toBeFalse();
expect($actionPhase->getError())->toBe('Action failed');
});
it('profiles cache operations', function () {
$session = $this->profiler->startSession('UserCard');
$result = $this->profiler->profileCache($session, 'get', function () {
return ['cached_data' => true];
});
expect($result)->toBe(['cached_data' => true]);
expect($session->getPhases())->toHaveCount(1);
$cachePhase = $session->getPhase('cache.get');
expect($cachePhase)->toBeInstanceOf(ProfilePhase::class);
expect($cachePhase->name)->toBe('cache.get');
expect($cachePhase->attributes['hit'])->toBeTrue();
});
it('takes memory snapshots', function () {
$session = $this->profiler->startSession('UserCard');
$snapshot = $this->profiler->takeMemorySnapshot($session, 'after_render');
expect($snapshot)->toBeInstanceOf(MemorySnapshot::class);
expect($snapshot->label)->toBe('after_render');
expect($snapshot->timestamp)->toBeInstanceOf(Timestamp::class);
expect($session->getMemorySnapshots())->toHaveCount(1);
});
it('ends session and returns result', function () {
$session = $this->profiler->startSession('UserCard');
// Profile some phases
$this->profiler->profileResolve($session, fn() => ['resolved' => true]);
$this->profiler->profileRender($session, fn() => '<div>Test</div>');
$result = $this->profiler->endSession($session);
expect($result)->toBeInstanceOf(ProfileResult::class);
expect($result->componentId)->toBe('UserCard');
expect($result->sessionId)->toBeInstanceOf(ProfileSessionId::class);
expect($result->totalDuration)->toBeInstanceOf(Duration::class);
expect($result->totalMemory)->toBeInstanceOf(Byte::class);
expect($result->phases)->toHaveCount(2);
});
it('provides timeline access', function () {
$timeline = $this->profiler->getTimeline();
expect($timeline)->toBeInstanceOf(ProfileTimeline::class);
});
});
describe('ProfileSessionId', function () {
it('generates unique session IDs', function () {
$id1 = ProfileSessionId::generate('UserCard');
$id2 = ProfileSessionId::generate('UserCard');
expect($id1->toString())->not->toBe($id2->toString());
expect($id1->toString())->toStartWith('UserCard_');
});
it('converts to string', function () {
$id = ProfileSessionId::generate('UserCard');
expect($id->toString())->toBeString();
expect(strlen($id->toString()))->toBeGreaterThan(10);
});
});
describe('ProfilePhase', function () {
it('creates from raw values', function () {
$phase = ProfilePhase::create(
name: 'render',
durationMs: 15.5,
memoryBytes: 2048,
attributes: ['cached' => true]
);
expect($phase)->toBeInstanceOf(ProfilePhase::class);
expect($phase->name)->toBe('render');
expect($phase->duration)->toBeInstanceOf(Duration::class);
expect($phase->memoryDelta)->toBeInstanceOf(Byte::class);
expect($phase->getDurationMs())->toBeFloat();
expect($phase->getMemoryBytes())->toBe(2048);
expect($phase->attributes)->toBe(['cached' => true]);
});
it('gets memory in megabytes', function () {
$phase = ProfilePhase::create(
name: 'render',
durationMs: 10.0,
memoryBytes: 1048576 // 1 MB
);
expect($phase->getMemoryMB())->toBe(1.0);
});
it('checks if phase was successful', function () {
$success = ProfilePhase::create(
name: 'render',
durationMs: 10.0,
memoryBytes: 1024,
attributes: ['success' => true]
);
$failure = ProfilePhase::create(
name: 'render',
durationMs: 10.0,
memoryBytes: 1024,
attributes: ['success' => false, 'error' => 'Render failed']
);
expect($success->isSuccessful())->toBeTrue();
expect($failure->isSuccessful())->toBeFalse();
expect($failure->getError())->toBe('Render failed');
});
it('converts to array', function () {
$phase = ProfilePhase::create(
name: 'render',
durationMs: 15.5,
memoryBytes: 2048
);
$array = $phase->toArray();
expect($array)->toHaveKeys([
'name',
'duration_ms',
'memory_bytes',
'memory_mb',
'attributes',
]);
});
});
describe('MemorySnapshot', function () {
it('creates from MemoryMonitor', function () {
$monitor = new MemoryMonitor();
$timestamp = Timestamp::now();
$snapshot = MemorySnapshot::fromMonitor('checkpoint', $monitor, $timestamp);
expect($snapshot)->toBeInstanceOf(MemorySnapshot::class);
expect($snapshot->label)->toBe('checkpoint');
expect($snapshot->currentUsage)->toBeInstanceOf(Byte::class);
expect($snapshot->peakUsage)->toBeInstanceOf(Byte::class);
expect($snapshot->timestamp)->toBe($timestamp);
});
it('takes snapshot now', function () {
$snapshot = MemorySnapshot::now('test');
expect($snapshot)->toBeInstanceOf(MemorySnapshot::class);
expect($snapshot->label)->toBe('test');
});
it('gets memory in MB', function () {
$snapshot = new MemorySnapshot(
label: 'test',
currentUsage: Byte::fromBytes(1048576), // 1 MB
peakUsage: Byte::fromBytes(2097152), // 2 MB
allocatedObjects: 100,
timestamp: Timestamp::now()
);
expect($snapshot->getCurrentMemoryMB())->toBe(1.0);
expect($snapshot->getPeakMemoryMB())->toBe(2.0);
});
it('calculates memory delta', function () {
$snapshot1 = new MemorySnapshot(
label: 'before',
currentUsage: Byte::fromBytes(1048576),
peakUsage: Byte::fromBytes(1048576),
allocatedObjects: 100,
timestamp: Timestamp::now()
);
$snapshot2 = new MemorySnapshot(
label: 'after',
currentUsage: Byte::fromBytes(2097152),
peakUsage: Byte::fromBytes(2097152),
allocatedObjects: 100,
timestamp: Timestamp::now()
);
$delta = $snapshot2->deltaFrom($snapshot1);
expect($delta->toBytes())->toBe(1048576); // 1 MB increase
});
it('checks if memory increased', function () {
$snapshot1 = new MemorySnapshot(
label: 'before',
currentUsage: Byte::fromBytes(1048576),
peakUsage: Byte::fromBytes(1048576),
allocatedObjects: 100,
timestamp: Timestamp::now()
);
$snapshot2 = new MemorySnapshot(
label: 'after',
currentUsage: Byte::fromBytes(2097152),
peakUsage: Byte::fromBytes(2097152),
allocatedObjects: 100,
timestamp: Timestamp::now()
);
expect($snapshot2->memoryIncreasedFrom($snapshot1))->toBeTrue();
expect($snapshot1->memoryIncreasedFrom($snapshot2))->toBeFalse();
});
it('converts to array', function () {
$snapshot = MemorySnapshot::now('test');
$array = $snapshot->toArray();
expect($array)->toHaveKeys([
'label',
'current_usage_bytes',
'current_usage_mb',
'peak_usage_bytes',
'peak_usage_mb',
'allocated_objects',
'timestamp',
]);
});
});

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Profiling\ProfileTimeline;
use App\Framework\LiveComponents\Profiling\ValueObjects\ProfileResult;
use App\Framework\LiveComponents\Profiling\ValueObjects\ProfileSessionId;
use App\Framework\LiveComponents\Profiling\ValueObjects\ProfilePhase;
use App\Framework\LiveComponents\Profiling\ValueObjects\MemorySnapshot;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Timestamp;
describe('ProfileTimeline', function () {
beforeEach(function () {
$this->timeline = new ProfileTimeline();
// Create test ProfileResult
$this->result = new ProfileResult(
sessionId: ProfileSessionId::generate('UserCard'),
componentId: 'UserCard',
totalDuration: Duration::fromMilliseconds(50.0),
totalMemory: Byte::fromBytes(4096),
phases: [
ProfilePhase::create('resolve', 10.0, 1024, ['success' => true]),
ProfilePhase::create('render', 30.0, 2048, ['cached' => false, 'success' => true]),
ProfilePhase::create('action.submit', 10.0, 1024, ['success' => true]),
],
memorySnapshots: [
new MemorySnapshot(
label: 'after_resolve',
currentUsage: Byte::fromBytes(1048576),
peakUsage: Byte::fromBytes(1048576),
allocatedObjects: 100,
timestamp: Timestamp::now()
),
new MemorySnapshot(
label: 'after_render',
currentUsage: Byte::fromBytes(2097152),
peakUsage: Byte::fromBytes(2097152),
allocatedObjects: 150,
timestamp: Timestamp::now()
),
],
startTime: Timestamp::now(),
endTime: Timestamp::now()
);
});
it('generates DevTools timeline', function () {
$timeline = $this->timeline->generateDevToolsTimeline($this->result);
expect($timeline)->toBeArray();
expect($timeline)->not->toBeEmpty();
// Should have metadata event
$metadataEvent = $timeline[0];
expect($metadataEvent['ph'])->toBe('M'); // Metadata
expect($metadataEvent['name'])->toBe('thread_name');
// Should have complete events for phases
$completeEvents = array_filter($timeline, fn($e) => $e['ph'] === 'X');
expect($completeEvents)->toHaveCount(3); // 3 phases
// Should have instant events for memory snapshots
$instantEvents = array_filter($timeline, fn($e) => $e['ph'] === 'i');
expect($instantEvents)->toHaveCount(2); // 2 snapshots
});
it('generates simple timeline', function () {
$timeline = $this->timeline->generateSimpleTimeline($this->result);
expect($timeline)->toBeArray();
expect($timeline)->toHaveKeys([
'component_id',
'session_id',
'total_duration_ms',
'total_memory_mb',
'start_time',
'end_time',
'phases',
'memory_snapshots',
]);
expect($timeline['component_id'])->toBe('UserCard');
expect($timeline['phases'])->toHaveCount(3);
expect($timeline['memory_snapshots'])->toHaveCount(2);
// Verify phase structure
$firstPhase = $timeline['phases'][0];
expect($firstPhase)->toHaveKeys([
'name',
'start_ms',
'end_ms',
'duration_ms',
'memory_mb',
'success',
'error',
]);
});
it('generates flamegraph data', function () {
$flamegraph = $this->timeline->generateFlamegraph($this->result);
expect($flamegraph)->toBeString();
expect($flamegraph)->toContain('livecomponent.UserCard;resolve');
expect($flamegraph)->toContain('livecomponent.UserCard;render');
expect($flamegraph)->toContain('livecomponent.UserCard;action.submit');
// Should have stack format: "stack_name count"
$lines = explode("\n", $flamegraph);
foreach ($lines as $line) {
if (!empty($line)) {
expect($line)->toMatch('/^[^;]+;[^\s]+ \d+$/');
}
}
});
it('generates Gantt chart data', function () {
$gantt = $this->timeline->generateGanttChart($this->result);
expect($gantt)->toBeArray();
expect($gantt)->toHaveKeys(['component_id', 'total_duration_ms', 'tasks']);
expect($gantt['tasks'])->toHaveCount(3);
// Verify task structure
$firstTask = $gantt['tasks'][0];
expect($firstTask)->toHaveKeys([
'id',
'name',
'start',
'end',
'duration',
'memory',
'success',
'type',
]);
expect($firstTask['name'])->toBe('Resolve');
expect($firstTask['type'])->toBe('initialization');
});
it('generates waterfall diagram', function () {
$waterfall = $this->timeline->generateWaterfall($this->result);
expect($waterfall)->toBeArray();
expect($waterfall)->toHaveKeys(['component_id', 'total_time', 'entries']);
expect($waterfall['entries'])->toHaveCount(3);
// Verify entry structure
$firstEntry = $waterfall['entries'][0];
expect($firstEntry)->toHaveKeys([
'name',
'start_time',
'duration',
'memory_delta',
'timing',
'status',
]);
expect($firstEntry['timing'])->toHaveKeys([
'blocked',
'execution',
'total',
]);
});
it('categorizes phases by type', function () {
$gantt = $this->timeline->generateGanttChart($this->result);
$resolveTask = $gantt['tasks'][0];
$renderTask = $gantt['tasks'][1];
$actionTask = $gantt['tasks'][2];
expect($resolveTask['type'])->toBe('initialization');
expect($renderTask['type'])->toBe('rendering');
expect($actionTask['type'])->toBe('interaction');
});
it('exports as JSON in different formats', function () {
$formats = ['devtools', 'simple', 'gantt', 'waterfall'];
foreach ($formats as $format) {
$json = $this->timeline->exportAsJson($this->result, $format);
expect($json)->toBeString();
$decoded = json_decode($json, true);
expect($decoded)->not->toBeNull();
expect(json_last_error())->toBe(JSON_ERROR_NONE);
}
});
it('throws exception for unknown format', function () {
expect(fn() => $this->timeline->exportAsJson($this->result, 'unknown'))
->toThrow(\InvalidArgumentException::class);
});
it('includes error stacks in flamegraph for failed phases', function () {
$resultWithError = new ProfileResult(
sessionId: ProfileSessionId::generate('UserCard'),
componentId: 'UserCard',
totalDuration: Duration::fromMilliseconds(20.0),
totalMemory: Byte::fromBytes(2048),
phases: [
ProfilePhase::create('render', 20.0, 2048, [
'success' => false,
'error' => 'Render failed'
]),
],
memorySnapshots: [],
startTime: Timestamp::now(),
endTime: Timestamp::now()
);
$flamegraph = $this->timeline->generateFlamegraph($resultWithError);
expect($flamegraph)->toContain('livecomponent.UserCard;render');
expect($flamegraph)->toContain('livecomponent.UserCard;render;error');
});
it('generates counter events for memory tracking in DevTools format', function () {
$timeline = $this->timeline->generateDevToolsTimeline($this->result);
$counterEvents = array_filter($timeline, fn($e) => $e['ph'] === 'C');
expect(count($counterEvents))->toBeGreaterThan(0);
foreach ($counterEvents as $event) {
expect($event)->toHaveKey('args');
expect($event['args'])->toHaveKey('memory_mb');
}
});
it('preserves phase timing in simple timeline', function () {
$timeline = $this->timeline->generateSimpleTimeline($this->result);
$phases = $timeline['phases'];
// First phase starts at 0
expect($phases[0]['start_ms'])->toBe(0.0);
expect($phases[0]['end_ms'])->toBe(10.0);
// Second phase starts where first ended
expect($phases[1]['start_ms'])->toBe(10.0);
expect($phases[1]['end_ms'])->toBe(40.0);
// Third phase starts where second ended
expect($phases[2]['start_ms'])->toBe(40.0);
expect($phases[2]['end_ms'])->toBe(50.0);
});
});

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Profiling;
use App\Framework\Random\RandomGenerator;
use App\Framework\Telemetry\OperationHandle;
use App\Framework\Telemetry\TelemetryService;
/**
* Simple Test Implementation of TelemetryService
*
* Minimal implementation for testing without complex dependencies.
* Provides valid OperationHandle instances but doesn't track anything.
*/
final class SimpleTelemetryService implements TelemetryService
{
/** @var array<string, mixed> */
private array $recordedMetrics = [];
/** @var array<string, mixed> */
private array $recordedEvents = [];
public function __construct(
private readonly RandomGenerator $randomGenerator
) {}
public function startOperation(
string $name,
string $type,
array $attributes = []
): OperationHandle {
// Create a valid OperationHandle with this service instance
$operationId = 'test-op-' . bin2hex($this->randomGenerator->bytes(4));
return new OperationHandle($operationId, $this);
}
public function trace(
string $name,
string $type,
callable $callback,
array $attributes = []
): mixed {
// Just execute the callback without tracing
return $callback();
}
public function recordMetric(
string $name,
float $value,
string $unit = '',
array $attributes = []
): void {
$this->recordedMetrics[] = [
'name' => $name,
'value' => $value,
'unit' => $unit,
'attributes' => $attributes,
];
}
public function recordEvent(
string $name,
array $attributes = [],
string $severity = 'info'
): void {
$this->recordedEvents[] = [
'name' => $name,
'attributes' => $attributes,
'severity' => $severity,
];
}
/**
* End an operation (called by OperationHandle)
*/
public function endOperation(string $operationId, ?string $status = null, ?string $errorMessage = null): void
{
// No-op for testing
}
/**
* Add an attribute to an operation
*/
public function addOperationAttribute(string $operationId, string $key, mixed $value): void
{
// No-op for testing
}
/**
* Get recorded metrics for test assertions
*/
public function getRecordedMetrics(): array
{
return $this->recordedMetrics;
}
/**
* Get recorded events for test assertions
*/
public function getRecordedEvents(): array
{
return $this->recordedEvents;
}
/**
* Clear all recorded data
*/
public function clear(): void
{
$this->recordedMetrics = [];
$this->recordedEvents = [];
}
}

View File

@@ -0,0 +1,386 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Serialization\EncryptedStateSerializer;
use App\Framework\LiveComponents\Serialization\StateEncryptor;
use App\Framework\LiveComponents\Exceptions\StateEncryptionException;
use App\Framework\StateManagement\SerializableState;
use App\Framework\Cryptography\CryptographicUtilities;
use App\Framework\Random\SecureRandomGenerator;
// Test State Value Object
final readonly class TestEncryptableState implements SerializableState
{
public function __construct(
public int $count,
public string $message,
public ?string $optional = null
) {
}
public function toArray(): array
{
return [
'count' => $this->count,
'message' => $this->message,
'optional' => $this->optional,
];
}
public static function fromArray(array $data): self
{
return new self(
count: $data['count'] ?? 0,
message: $data['message'] ?? '',
optional: $data['optional'] ?? null
);
}
}
describe('EncryptedStateSerializer', function () {
beforeEach(function () {
// Use framework's RandomGenerator for cryptographically secure key
$this->random = new SecureRandomGenerator();
$this->crypto = new CryptographicUtilities($this->random);
// Generate valid 32-byte encryption key using framework's RandomGenerator
$this->encryptionKey = $this->random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$this->encryptor = new StateEncryptor(
$this->encryptionKey,
$this->crypto,
$this->random
);
$this->serializer = new EncryptedStateSerializer($this->encryptor);
$this->testState = new TestEncryptableState(
count: 42,
message: 'Hello, encrypted world!',
optional: 'optional data'
);
});
it('serializes and encrypts state to string', function () {
$encrypted = $this->serializer->serialize($this->testState);
expect($encrypted)->toBeString();
expect($encrypted)->not->toBeEmpty();
// Encrypted data should be base64-encoded
$decoded = base64_decode($encrypted, strict: true);
expect($decoded)->not->toBeFalse();
// Should be longer than original due to nonce and MAC
expect(strlen($encrypted))->toBeGreaterThan(50);
});
it('deserializes and decrypts state correctly', function () {
$encrypted = $this->serializer->serialize($this->testState);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted)->toBeInstanceOf(TestEncryptableState::class);
expect($decrypted->count)->toBe(42);
expect($decrypted->message)->toBe('Hello, encrypted world!');
expect($decrypted->optional)->toBe('optional data');
});
it('produces different ciphertext for same state due to unique nonces', function () {
$encrypted1 = $this->serializer->serialize($this->testState);
$encrypted2 = $this->serializer->serialize($this->testState);
// Same plaintext should produce different ciphertext (unique nonces)
expect($encrypted1)->not->toBe($encrypted2);
// But both should decrypt to same state
$decrypted1 = $this->serializer->deserialize($encrypted1, TestEncryptableState::class);
$decrypted2 = $this->serializer->deserialize($encrypted2, TestEncryptableState::class);
expect($decrypted1->count)->toBe($decrypted2->count);
expect($decrypted1->message)->toBe($decrypted2->message);
});
it('handles state with null optional field', function () {
$state = new TestEncryptableState(
count: 10,
message: 'test',
optional: null
);
$encrypted = $this->serializer->serialize($state);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted->optional)->toBeNull();
});
it('handles state with special characters', function () {
$state = new TestEncryptableState(
count: 123,
message: "Special chars: 🔐 <script>alert('xss')</script> '; DROP TABLE users;--",
optional: "Unicode: 你好世界 مرحبا العالم"
);
$encrypted = $this->serializer->serialize($state);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted->message)->toBe($state->message);
expect($decrypted->optional)->toBe($state->optional);
});
it('throws exception for invalid encrypted data', function () {
$this->serializer->deserialize('invalid-base64-data', TestEncryptableState::class);
})->throws(StateEncryptionException::class);
it('throws exception for tampered ciphertext', function () {
$encrypted = $this->serializer->serialize($this->testState);
// Tamper with encrypted data
$tamperedEncrypted = substr($encrypted, 0, -5) . 'XXXXX';
$this->serializer->deserialize($tamperedEncrypted, TestEncryptableState::class);
})->throws(StateEncryptionException::class);
it('throws exception for invalid state class', function () {
$encrypted = $this->serializer->serialize($this->testState);
// Try to deserialize to wrong class
$this->serializer->deserialize($encrypted, \stdClass::class);
})->throws(StateEncryptionException::class, 'must implement fromArray');
it('throws exception for state without toArray method', function () {
$invalidState = new class {
public int $value = 42;
};
$this->serializer->serialize($invalidState);
})->throws(StateEncryptionException::class, 'must implement toArray');
it('throws exception for corrupted JSON in ciphertext', function () {
// Create manually corrupted encrypted data with valid structure but invalid JSON
$nonce = random_bytes(24);
$invalidJson = '{invalid json}';
$ciphertext = sodium_crypto_secretbox($invalidJson, $nonce, $this->encryptionKey);
$versionByte = chr(1);
$encrypted = base64_encode($versionByte . $nonce . $ciphertext);
$this->serializer->deserialize($encrypted, TestEncryptableState::class);
})->throws(StateEncryptionException::class);
it('preserves data types through encryption cycle', function () {
$state = new TestEncryptableState(
count: 0, // Zero value
message: '', // Empty string
optional: null
);
$encrypted = $this->serializer->serialize($state);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted->count)->toBe(0);
expect($decrypted->count)->toBeInt();
expect($decrypted->message)->toBe('');
expect($decrypted->message)->toBeString();
expect($decrypted->optional)->toBeNull();
});
it('handles large state objects', function () {
$largeMessage = str_repeat('A', 10000); // 10KB string
$state = new TestEncryptableState(
count: 999999,
message: $largeMessage,
optional: str_repeat('B', 5000)
);
$encrypted = $this->serializer->serialize($state);
$decrypted = $this->serializer->deserialize($encrypted, TestEncryptableState::class);
expect($decrypted->message)->toBe($largeMessage);
expect(strlen($decrypted->message))->toBe(10000);
});
});
describe('StateEncryptor', function () {
beforeEach(function () {
$this->random = new SecureRandomGenerator();
$this->crypto = new CryptographicUtilities($this->random);
$this->encryptionKey = $this->random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$this->encryptor = new StateEncryptor(
$this->encryptionKey,
$this->crypto,
$this->random
);
});
it('encrypts and decrypts plaintext correctly', function () {
$plaintext = 'Secret message';
$encrypted = $this->encryptor->encrypt($plaintext);
$decrypted = $this->encryptor->decrypt($encrypted);
expect($decrypted)->toBe($plaintext);
});
it('produces different ciphertext for same plaintext', function () {
$plaintext = 'Same message';
$encrypted1 = $this->encryptor->encrypt($plaintext);
$encrypted2 = $this->encryptor->encrypt($plaintext);
expect($encrypted1)->not->toBe($encrypted2);
expect($this->encryptor->decrypt($encrypted1))->toBe($plaintext);
expect($this->encryptor->decrypt($encrypted2))->toBe($plaintext);
});
it('throws exception for invalid encryption key length', function () {
new StateEncryptor(
'too-short-key', // Invalid key length
$this->crypto,
$this->random
);
})->throws(\InvalidArgumentException::class, 'Encryption key must be');
it('throws exception for weak encryption key', function () {
$random = new SecureRandomGenerator();
$crypto = new CryptographicUtilities($random);
// All zeros - weak key (insufficient entropy)
$weakKey = str_repeat("\0", SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
new StateEncryptor(
$weakKey,
$crypto,
$random
);
})->throws(\InvalidArgumentException::class, 'insufficient entropy');
it('detects MAC tampering', function () {
$encrypted = $this->encryptor->encrypt('original message');
// Tamper with MAC by modifying last bytes
$decoded = base64_decode($encrypted, strict: true);
$tampered = substr($decoded, 0, -5) . 'XXXXX';
$tamperedEncrypted = base64_encode($tampered);
$this->encryptor->decrypt($tamperedEncrypted);
})->throws(StateEncryptionException::class, 'MAC verification failed');
it('throws exception for corrupted base64', function () {
$this->encryptor->decrypt('!!!invalid-base64!!!');
})->throws(StateEncryptionException::class);
it('throws exception for invalid encryption version', function () {
// Create encrypted data with invalid version byte
$nonce = random_bytes(24);
$plaintext = 'test';
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->encryptionKey);
$invalidVersion = chr(99); // Invalid version
$encrypted = base64_encode($invalidVersion . $nonce . $ciphertext);
$this->encryptor->decrypt($encrypted);
})->throws(StateEncryptionException::class, 'Unsupported encryption version');
it('handles empty plaintext', function () {
$encrypted = $this->encryptor->encrypt('');
$decrypted = $this->encryptor->decrypt($encrypted);
expect($decrypted)->toBe('');
});
it('handles unicode plaintext', function () {
$plaintext = '🔐 Encrypted Unicode: 你好世界 مرحبا العالم';
$encrypted = $this->encryptor->encrypt($plaintext);
$decrypted = $this->encryptor->decrypt($encrypted);
expect($decrypted)->toBe($plaintext);
});
it('validates encrypted data format', function () {
$encrypted = $this->encryptor->encrypt('test');
expect($this->encryptor->isEncrypted($encrypted))->toBeTrue();
expect($this->encryptor->isEncrypted('not-encrypted'))->toBeFalse();
expect($this->encryptor->isEncrypted(''))->toBeFalse();
});
});
describe('StateEncryptor Security', function () {
beforeEach(function () {
$this->random = new SecureRandomGenerator();
$this->crypto = new CryptographicUtilities($this->random);
$this->encryptionKey = $this->random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$this->encryptor = new StateEncryptor(
$this->encryptionKey,
$this->crypto,
$this->random
);
});
it('uses different nonce for each encryption', function () {
$plaintext = 'test';
$nonces = [];
for ($i = 0; $i < 100; $i++) {
$encrypted = $this->encryptor->encrypt($plaintext);
$decoded = base64_decode($encrypted, strict: true);
// Minimum length check: 1 (version) + 24 (nonce) + 16 (min ciphertext from sodium_crypto_secretbox)
expect(strlen($decoded))->toBeGreaterThanOrEqual(41);
// Extract nonce (after version byte, 24 bytes)
$nonce = substr($decoded, 1, 24);
expect(strlen($nonce))->toBe(24);
expect($nonces)->not->toContain($nonce);
$nonces[] = $nonce;
}
// All nonces should be unique
expect(count($nonces))->toBe(100);
expect(count(array_unique($nonces, SORT_REGULAR)))->toBe(100);
});
it('prevents key reuse across different encryptors', function () {
$plaintext = 'secret';
$random = new SecureRandomGenerator();
$crypto = new CryptographicUtilities($random);
$encryptor1 = new StateEncryptor(
$this->encryptionKey,
$crypto,
$random
);
// Different key using framework's RandomGenerator
$differentKey = $random->bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
$encryptor2 = new StateEncryptor(
$differentKey,
$crypto,
$random
);
$encrypted1 = $encryptor1->encrypt($plaintext);
// Should fail with wrong key
$encryptor2->decrypt($encrypted1);
})->throws(StateEncryptionException::class);
it('includes version byte for future compatibility', function () {
$encrypted = $this->encryptor->encrypt('test');
$decoded = base64_decode($encrypted, strict: true);
// First byte should be version byte (value 1)
$version = ord($decoded[0]);
expect($version)->toBe(1);
});
});

View File

@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Filesystem\FileStorage;
use App\Framework\LiveComponents\Services\ChunkAssembler;
describe('ChunkAssembler Service', function () {
beforeEach(function () {
// Create temporary directory for test files
$this->testDir = sys_get_temp_dir() . '/chunk_assembler_test_' . uniqid();
mkdir($this->testDir);
$this->fileStorage = new FileStorage();
$this->assembler = new ChunkAssembler($this->fileStorage);
});
afterEach(function () {
// Clean up test directory
if (is_dir($this->testDir)) {
$files = glob($this->testDir . '/*');
foreach ($files as $file) {
if (is_file($file)) {
unlink($file);
}
}
rmdir($this->testDir);
}
});
it('assembles multiple chunks into single file', function () {
// Create 3 test chunks
$chunk1Path = $this->testDir . '/chunk_0';
$chunk2Path = $this->testDir . '/chunk_1';
$chunk3Path = $this->testDir . '/chunk_2';
file_put_contents($chunk1Path, 'chunk 1 data');
file_put_contents($chunk2Path, 'chunk 2 data');
file_put_contents($chunk3Path, 'chunk 3 data');
$targetPath = $this->testDir . '/assembled.txt';
$this->assembler->assemble(
[$chunk1Path, $chunk2Path, $chunk3Path],
$targetPath
);
expect(file_exists($targetPath))->toBeTrue();
expect(file_get_contents($targetPath))->toBe('chunk 1 datachunk 2 datachunk 3 data');
});
it('assembles single chunk', function () {
$chunkPath = $this->testDir . '/chunk_0';
file_put_contents($chunkPath, 'single chunk data');
$targetPath = $this->testDir . '/assembled.txt';
$this->assembler->assemble([$chunkPath], $targetPath);
expect(file_exists($targetPath))->toBeTrue();
expect(file_get_contents($targetPath))->toBe('single chunk data');
});
it('throws exception for empty chunk array', function () {
$targetPath = $this->testDir . '/assembled.txt';
expect(fn() => $this->assembler->assemble([], $targetPath))
->toThrow(InvalidArgumentException::class);
});
it('throws exception for missing chunk file', function () {
$existingChunk = $this->testDir . '/chunk_0';
$missingChunk = $this->testDir . '/chunk_1_missing';
file_put_contents($existingChunk, 'chunk data');
$targetPath = $this->testDir . '/assembled.txt';
expect(fn() => $this->assembler->assemble(
[$existingChunk, $missingChunk],
$targetPath
))->toThrow(InvalidArgumentException::class);
});
it('assembles large chunks efficiently', function () {
// Create 3 chunks of 1MB each
$chunk1Path = $this->testDir . '/chunk_0';
$chunk2Path = $this->testDir . '/chunk_1';
$chunk3Path = $this->testDir . '/chunk_2';
file_put_contents($chunk1Path, str_repeat('A', 1024 * 1024));
file_put_contents($chunk2Path, str_repeat('B', 1024 * 1024));
file_put_contents($chunk3Path, str_repeat('C', 1024 * 1024));
$targetPath = $this->testDir . '/assembled_large.bin';
$this->assembler->assemble(
[$chunk1Path, $chunk2Path, $chunk3Path],
$targetPath
);
expect(file_exists($targetPath))->toBeTrue();
expect(filesize($targetPath))->toBe(3 * 1024 * 1024);
});
it('calculates total size correctly', function () {
$chunk1Path = $this->testDir . '/chunk_0';
$chunk2Path = $this->testDir . '/chunk_1';
file_put_contents($chunk1Path, str_repeat('A', 1024)); // 1KB
file_put_contents($chunk2Path, str_repeat('B', 2048)); // 2KB
$totalSize = $this->assembler->calculateTotalSize([$chunk1Path, $chunk2Path]);
expect($totalSize->toBytes())->toBe(3072); // 3KB
expect($totalSize->toKilobytes())->toBe(3.0);
});
it('handles empty chunk files', function () {
$chunk1Path = $this->testDir . '/chunk_0';
$chunk2Path = $this->testDir . '/chunk_1';
file_put_contents($chunk1Path, 'data');
file_put_contents($chunk2Path, ''); // Empty chunk
$targetPath = $this->testDir . '/assembled.txt';
$this->assembler->assemble([$chunk1Path, $chunk2Path], $targetPath);
expect(file_exists($targetPath))->toBeTrue();
expect(file_get_contents($targetPath))->toBe('data');
});
it('preserves chunk order during assembly', function () {
$paths = [];
for ($i = 0; $i < 5; $i++) {
$path = $this->testDir . "/chunk_$i";
file_put_contents($path, "chunk$i");
$paths[] = $path;
}
$targetPath = $this->testDir . '/assembled.txt';
$this->assembler->assemble($paths, $targetPath);
expect(file_get_contents($targetPath))->toBe('chunk0chunk1chunk2chunk3chunk4');
});
it('assembles binary data correctly', function () {
$chunk1Path = $this->testDir . '/chunk_0';
$chunk2Path = $this->testDir . '/chunk_1';
$binaryData1 = random_bytes(256);
$binaryData2 = random_bytes(256);
file_put_contents($chunk1Path, $binaryData1);
file_put_contents($chunk2Path, $binaryData2);
$targetPath = $this->testDir . '/assembled.bin';
$this->assembler->assemble([$chunk1Path, $chunk2Path], $targetPath);
$assembled = file_get_contents($targetPath);
expect($assembled)->toBe($binaryData1 . $binaryData2);
});
it('returns zero size for no chunks', function () {
$totalSize = $this->assembler->calculateTotalSize([]);
expect($totalSize->toBytes())->toBe(0);
expect($totalSize->isEmpty())->toBeTrue();
});
it('skips missing chunks in size calculation', function () {
$chunk1Path = $this->testDir . '/chunk_0';
$missingPath = $this->testDir . '/chunk_missing';
file_put_contents($chunk1Path, str_repeat('A', 1024));
$totalSize = $this->assembler->calculateTotalSize([$chunk1Path, $missingPath]);
expect($totalSize->toBytes())->toBe(1024); // Only existing chunk counted
});
it('uses custom buffer size when provided', function () {
$customBufferSize = Byte::fromKilobytes(16);
$assembler = new ChunkAssembler($this->fileStorage, $customBufferSize);
$chunkPath = $this->testDir . '/chunk_0';
file_put_contents($chunkPath, str_repeat('A', 32 * 1024)); // 32KB
$targetPath = $this->testDir . '/assembled.txt';
$assembler->assemble([$chunkPath], $targetPath);
expect(file_exists($targetPath))->toBeTrue();
expect(filesize($targetPath))->toBe(32 * 1024);
});
});

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Services\IntegrityValidator;
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
describe('IntegrityValidator Service', function () {
it('verifies chunk data with matching hash', function () {
$validator = new IntegrityValidator();
$data = 'test chunk data';
$hash = ChunkHash::fromData($data);
expect($validator->verifyChunk($data, $hash))->toBeTrue();
});
it('rejects chunk data with mismatched hash', function () {
$validator = new IntegrityValidator();
$data = 'test chunk data';
$wrongData = 'different chunk data';
$hash = ChunkHash::fromData($data);
expect($validator->verifyChunk($wrongData, $hash))->toBeFalse();
});
it('verifies file with matching hash', function () {
$validator = new IntegrityValidator();
$tempFile = tempnam(sys_get_temp_dir(), 'integrity_test_');
file_put_contents($tempFile, 'test file content');
try {
$hash = ChunkHash::fromFile($tempFile);
expect($validator->verifyFile($tempFile, $hash))->toBeTrue();
} finally {
unlink($tempFile);
}
});
it('rejects file with mismatched hash', function () {
$validator = new IntegrityValidator();
$tempFile = tempnam(sys_get_temp_dir(), 'integrity_test_');
file_put_contents($tempFile, 'original content');
try {
$hash = ChunkHash::fromFile($tempFile);
// Modify file content
file_put_contents($tempFile, 'modified content');
expect($validator->verifyFile($tempFile, $hash))->toBeFalse();
} finally {
unlink($tempFile);
}
});
it('rejects verification for non-existent file', function () {
$validator = new IntegrityValidator();
$nonExistentFile = '/tmp/does_not_exist_' . uniqid();
$hash = ChunkHash::fromData('some data');
expect($validator->verifyFile($nonExistentFile, $hash))->toBeFalse();
});
it('calculates chunk hash correctly', function () {
$validator = new IntegrityValidator();
$data = 'test chunk data';
$hash = $validator->calculateChunkHash($data);
expect($hash)->toBeInstanceOf(ChunkHash::class);
expect($hash->verify($data))->toBeTrue();
});
it('calculates file hash correctly', function () {
$validator = new IntegrityValidator();
$tempFile = tempnam(sys_get_temp_dir(), 'integrity_test_');
file_put_contents($tempFile, 'test file content');
try {
$hash = $validator->calculateFileHash($tempFile);
expect($hash)->toBeInstanceOf(ChunkHash::class);
expect($hash->verifyFile($tempFile))->toBeTrue();
} finally {
unlink($tempFile);
}
});
it('produces consistent hashes for same data', function () {
$validator = new IntegrityValidator();
$data = 'consistent test data';
$hash1 = $validator->calculateChunkHash($data);
$hash2 = $validator->calculateChunkHash($data);
expect($hash1->equals($hash2))->toBeTrue();
});
it('produces different hashes for different data', function () {
$validator = new IntegrityValidator();
$data1 = 'first chunk';
$data2 = 'second chunk';
$hash1 = $validator->calculateChunkHash($data1);
$hash2 = $validator->calculateChunkHash($data2);
expect($hash1->equals($hash2))->toBeFalse();
});
it('verifies large chunk data correctly', function () {
$validator = new IntegrityValidator();
// Create 1MB of data
$largeData = str_repeat('A', 1024 * 1024);
$hash = ChunkHash::fromData($largeData);
expect($validator->verifyChunk($largeData, $hash))->toBeTrue();
});
it('verifies empty chunk data', function () {
$validator = new IntegrityValidator();
$emptyData = '';
$hash = ChunkHash::fromData($emptyData);
expect($validator->verifyChunk($emptyData, $hash))->toBeTrue();
});
it('detects corrupted data in middle of chunk', function () {
$validator = new IntegrityValidator();
$originalData = 'start' . str_repeat('middle', 1000) . 'end';
$hash = ChunkHash::fromData($originalData);
$corruptedData = 'start' . str_repeat('CORRUPTED', 1000) . 'end';
expect($validator->verifyChunk($corruptedData, $hash))->toBeFalse();
});
it('handles binary data correctly', function () {
$validator = new IntegrityValidator();
$binaryData = random_bytes(1024);
$hash = $validator->calculateChunkHash($binaryData);
expect($validator->verifyChunk($binaryData, $hash))->toBeTrue();
});
});

View File

@@ -0,0 +1,228 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Services\UploadSessionIdGenerator;
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
use App\Framework\Random\RandomGenerator;
describe('UploadSessionIdGenerator Service', function () {
it('generates valid session id', function () {
$randomGen = new class implements RandomGenerator {
public function bytes(int $length): string
{
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$generator = new UploadSessionIdGenerator($randomGen);
$sessionId = $generator->generate();
expect($sessionId)->toBeInstanceOf(UploadSessionId::class);
expect(strlen($sessionId->value))->toBe(32); // 16 bytes = 32 hex chars
});
it('generates hex-encoded session ids', function () {
$randomGen = new class implements RandomGenerator {
public function bytes(int $length): string
{
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$generator = new UploadSessionIdGenerator($randomGen);
$sessionId = $generator->generate();
// Verify it's valid hex
expect(ctype_xdigit($sessionId->value))->toBeTrue();
});
it('generates unique session ids', function () {
$randomGen = new class implements RandomGenerator {
public function bytes(int $length): string
{
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$generator = new UploadSessionIdGenerator($randomGen);
$id1 = $generator->generate();
$id2 = $generator->generate();
$id3 = $generator->generate();
expect($id1->equals($id2))->toBeFalse();
expect($id1->equals($id3))->toBeFalse();
expect($id2->equals($id3))->toBeFalse();
});
it('generates 100 unique session ids', function () {
$randomGen = new class implements RandomGenerator {
public function bytes(int $length): string
{
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$generator = new UploadSessionIdGenerator($randomGen);
$sessionIds = [];
for ($i = 0; $i < 100; $i++) {
$sessionIds[] = $generator->generate()->value;
}
$uniqueIds = array_unique($sessionIds);
expect(count($uniqueIds))->toBe(100);
});
it('requests 16 bytes from random generator', function () {
$randomGen = new class implements RandomGenerator {
public int $lastRequestedLength = 0;
public function bytes(int $length): string
{
$this->lastRequestedLength = $length;
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$generator = new UploadSessionIdGenerator($randomGen);
$generator->generate();
expect($randomGen->lastRequestedLength)->toBe(16);
});
it('uses RandomGenerator bytes method', function () {
$randomGen = new class implements RandomGenerator {
public bool $bytesCalled = false;
public function bytes(int $length): string
{
$this->bytesCalled = true;
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$generator = new UploadSessionIdGenerator($randomGen);
$generator->generate();
expect($randomGen->bytesCalled)->toBeTrue();
});
it('produces alphanumeric session ids', function () {
$randomGen = new class implements RandomGenerator {
public function bytes(int $length): string
{
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$generator = new UploadSessionIdGenerator($randomGen);
for ($i = 0; $i < 10; $i++) {
$sessionId = $generator->generate();
expect(ctype_alnum($sessionId->value))->toBeTrue();
}
});
it('generates consistent length session ids', function () {
$randomGen = new class implements RandomGenerator {
public function bytes(int $length): string
{
return random_bytes($length);
}
public function int(int $min, int $max): int
{
return random_int($min, $max);
}
public function float(): float
{
return (float) random_int(0, PHP_INT_MAX) / PHP_INT_MAX;
}
};
$generator = new UploadSessionIdGenerator($randomGen);
$lengths = [];
for ($i = 0; $i < 50; $i++) {
$sessionId = $generator->generate();
$lengths[] = strlen($sessionId->value);
}
$uniqueLengths = array_unique($lengths);
expect(count($uniqueLengths))->toBe(1);
expect($uniqueLengths[0])->toBe(32);
});
});

View File

@@ -0,0 +1,423 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Contracts\SupportsSlots;
use App\Framework\LiveComponents\SlotManager;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\SlotContent;
use App\Framework\LiveComponents\ValueObjects\SlotContext;
use App\Framework\LiveComponents\ValueObjects\SlotDefinition;
describe('SlotManager', function () {
beforeEach(function () {
$this->slotManager = new SlotManager();
});
it('registers and retrieves slot contents', function () {
$componentId = ComponentId::generate();
$contents = [
SlotContent::named('header', '<h1>Header</h1>'),
SlotContent::named('body', '<p>Body</p>'),
];
$this->slotManager->registerSlotContents($componentId, $contents);
$retrieved = $this->slotManager->getSlotContents($componentId);
expect($retrieved)->toHaveCount(2);
expect($retrieved[0]->slotName)->toBe('header');
expect($retrieved[1]->slotName)->toBe('body');
});
it('resolves provided content over default content', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [
SlotDefinition::named('header', '<h2>Default Header</h2>'),
];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::empty();
}
public function processSlotContent(SlotContent $content): SlotContent
{
return $content;
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
$definition = SlotDefinition::named('header', '<h2>Default Header</h2>');
$providedContent = [
SlotContent::named('header', '<h1>Custom Header</h1>'),
];
$result = $this->slotManager->resolveSlotContent(
$component,
$definition,
$providedContent
);
expect($result)->toBe('<h1>Custom Header</h1>');
});
it('uses default content when no content provided', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [
SlotDefinition::named('header', '<h2>Default Header</h2>'),
];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::empty();
}
public function processSlotContent(SlotContent $content): SlotContent
{
return $content;
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
$definition = SlotDefinition::named('header', '<h2>Default Header</h2>');
$providedContent = [];
$result = $this->slotManager->resolveSlotContent(
$component,
$definition,
$providedContent
);
expect($result)->toBe('<h2>Default Header</h2>');
});
it('injects scoped context into slot content', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [
SlotDefinition::scoped('content', ['userId', 'userName']),
];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::create([
'userId' => 123,
'userName' => 'John Doe',
]);
}
public function processSlotContent(SlotContent $content): SlotContent
{
return $content;
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
$definition = SlotDefinition::scoped('content', ['userId', 'userName']);
$providedContent = [
SlotContent::named('content', '<p>User: {context.userName} (ID: {context.userId})</p>'),
];
$result = $this->slotManager->resolveSlotContent(
$component,
$definition,
$providedContent
);
expect($result)->toBe('<p>User: John Doe (ID: 123)</p>');
});
it('validates required slots', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [
SlotDefinition::named('body')->withRequired(true),
SlotDefinition::named('footer'),
];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::empty();
}
public function processSlotContent(SlotContent $content): SlotContent
{
return $content;
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
// No content provided
$errors = $this->slotManager->validateSlots($component, []);
expect($errors)->toContain("Required slot 'body' is not filled");
});
it('validates slots with content provided', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [
SlotDefinition::named('body')->withRequired(true),
];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::empty();
}
public function processSlotContent(SlotContent $content): SlotContent
{
return $content;
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
$providedContent = [
SlotContent::named('body', '<p>Body content</p>'),
];
$errors = $this->slotManager->validateSlots($component, $providedContent);
expect($errors)->toBeEmpty();
});
it('checks if component has specific slot', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [
SlotDefinition::named('header'),
SlotDefinition::named('body'),
];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::empty();
}
public function processSlotContent(SlotContent $content): SlotContent
{
return $content;
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
expect($this->slotManager->hasSlot($component, 'header'))->toBeTrue();
expect($this->slotManager->hasSlot($component, 'body'))->toBeTrue();
expect($this->slotManager->hasSlot($component, 'footer'))->toBeFalse();
});
it('gets slot definition by name', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [
SlotDefinition::named('header', '<h1>Default</h1>'),
];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::empty();
}
public function processSlotContent(SlotContent $content): SlotContent
{
return $content;
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
$definition = $this->slotManager->getSlotDefinition($component, 'header');
expect($definition)->not->toBeNull();
expect($definition->name)->toBe('header');
expect($definition->defaultContent)->toBe('<h1>Default</h1>');
});
it('returns null for unknown slot', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::empty();
}
public function processSlotContent(SlotContent $content): SlotContent
{
return $content;
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
$definition = $this->slotManager->getSlotDefinition($component, 'unknown');
expect($definition)->toBeNull();
});
it('processes slot content through component hook', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [
SlotDefinition::named('body'),
];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::empty();
}
public function processSlotContent(SlotContent $content): SlotContent
{
// Wrap content in div
return $content->withContent('<div class="wrapper">' . $content->content . '</div>');
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
$definition = SlotDefinition::named('body');
$providedContent = [
SlotContent::named('body', '<p>Content</p>'),
];
$result = $this->slotManager->resolveSlotContent(
$component,
$definition,
$providedContent
);
expect($result)->toBe('<div class="wrapper"><p>Content</p></div>');
});
it('escapes HTML in scoped context values', function () {
$component = new class () implements SupportsSlots {
public function getSlotDefinitions(): array
{
return [
SlotDefinition::scoped('content', ['userInput']),
];
}
public function getSlotContext(string $slotName): SlotContext
{
return SlotContext::create([
'userInput' => '<script>alert("XSS")</script>',
]);
}
public function processSlotContent(SlotContent $content): SlotContent
{
return $content;
}
public function validateSlots(array $providedSlots): array
{
return [];
}
};
$definition = SlotDefinition::scoped('content', ['userInput']);
$providedContent = [
SlotContent::named('content', '<p>{context.userInput}</p>'),
];
$result = $this->slotManager->resolveSlotContent(
$component,
$definition,
$providedContent
);
expect($result)->toContain('&lt;script&gt;');
expect($result)->not->toContain('<script>');
});
it('provides slot statistics', function () {
$componentId1 = ComponentId::generate();
$componentId2 = ComponentId::generate();
$this->slotManager->registerSlotContents($componentId1, [
SlotContent::named('header', '<h1>H1</h1>'),
SlotContent::named('body', '<p>Body</p>'),
]);
$this->slotManager->registerSlotContents($componentId2, [
SlotContent::named('footer', '<footer>Footer</footer>'),
]);
$stats = $this->slotManager->getStats();
expect($stats['total_components_with_slots'])->toBe(2);
expect($stats['total_slot_contents'])->toBe(3);
expect($stats['avg_slots_per_component'])->toBe(1.5);
});
it('clears all registered slot contents', function () {
$componentId = ComponentId::generate();
$this->slotManager->registerSlotContents($componentId, [
SlotContent::named('header', '<h1>Header</h1>'),
]);
expect($this->slotManager->getSlotContents($componentId))->toHaveCount(1);
$this->slotManager->clear();
expect($this->slotManager->getSlotContents($componentId))->toBeEmpty();
});
});

View File

@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\SlotContent;
use App\Framework\LiveComponents\ValueObjects\SlotContext;
use App\Framework\LiveComponents\ValueObjects\SlotDefinition;
describe('SlotDefinition', function () {
it('creates default slot', function () {
$slot = SlotDefinition::default('<p>Default content</p>');
expect($slot->name)->toBe('default');
expect($slot->defaultContent)->toBe('<p>Default content</p>');
expect($slot->required)->toBeFalse();
expect($slot->isDefault())->toBeTrue();
expect($slot->isScoped())->toBeFalse();
});
it('creates named slot', function () {
$slot = SlotDefinition::named('header', '<h1>Default Header</h1>');
expect($slot->name)->toBe('header');
expect($slot->defaultContent)->toBe('<h1>Default Header</h1>');
expect($slot->isDefault())->toBeFalse();
});
it('creates scoped slot with props', function () {
$slot = SlotDefinition::scoped('content', ['userId', 'userName']);
expect($slot->name)->toBe('content');
expect($slot->props)->toBe(['userId', 'userName']);
expect($slot->isScoped())->toBeTrue();
});
it('marks slot as required', function () {
$slot = SlotDefinition::named('body')->withRequired(true);
expect($slot->required)->toBeTrue();
});
it('updates default content', function () {
$slot = SlotDefinition::named('header')
->withDefaultContent('<h2>New Default</h2>');
expect($slot->defaultContent)->toBe('<h2>New Default</h2>');
});
it('updates props', function () {
$slot = SlotDefinition::named('content')
->withProps(['prop1', 'prop2']);
expect($slot->props)->toBe(['prop1', 'prop2']);
expect($slot->isScoped())->toBeTrue();
});
it('checks if has default content', function () {
$slotWithDefault = SlotDefinition::named('header', '<h1>Default</h1>');
$slotWithoutDefault = SlotDefinition::named('header', '');
expect($slotWithDefault->hasDefaultContent())->toBeTrue();
expect($slotWithoutDefault->hasDefaultContent())->toBeFalse();
});
it('throws on empty slot name', function () {
expect(fn () => new SlotDefinition(''))->toThrow(\InvalidArgumentException::class);
});
it('converts to array for serialization', function () {
$slot = SlotDefinition::scoped('content', ['key1', 'key2'], '<p>Default</p>')
->withRequired(true);
$array = $slot->toArray();
expect($array['name'])->toBe('content');
expect($array['default_content'])->toBe('<p>Default</p>');
expect($array['props'])->toBe(['key1', 'key2']);
expect($array['required'])->toBeTrue();
expect($array['is_scoped'])->toBeTrue();
});
it('checks equality', function () {
$slot1 = SlotDefinition::named('header', '<h1>Header</h1>');
$slot2 = SlotDefinition::named('header', '<h1>Header</h1>');
$slot3 = SlotDefinition::named('header', '<h2>Different</h2>');
expect($slot1->equals($slot2))->toBeTrue();
expect($slot1->equals($slot3))->toBeFalse();
});
});
describe('SlotContent', function () {
it('creates default slot content', function () {
$content = SlotContent::default('<p>Content</p>');
expect($content->slotName)->toBe('default');
expect($content->content)->toBe('<p>Content</p>');
expect($content->isDefault())->toBeTrue();
});
it('creates named slot content', function () {
$content = SlotContent::named('header', '<h1>Header</h1>');
expect($content->slotName)->toBe('header');
expect($content->content)->toBe('<h1>Header</h1>');
expect($content->isDefault())->toBeFalse();
});
it('creates slot content with data', function () {
$content = SlotContent::named('content', '<p>{title}</p>', ['title' => 'My Title']);
expect($content->hasData())->toBeTrue();
expect($content->getData('title'))->toBe('My Title');
});
it('creates slot content from component', function () {
$componentId = ComponentId::generate();
$content = SlotContent::fromComponent('header', $componentId, '<h1>Header</h1>');
expect($content->isFromComponent())->toBeTrue();
expect($content->componentId)->toBe($componentId);
});
it('checks if content is empty', function () {
$emptyContent = SlotContent::named('header', ' ');
$filledContent = SlotContent::named('header', '<h1>Header</h1>');
expect($emptyContent->isEmpty())->toBeTrue();
expect($filledContent->isEmpty())->toBeFalse();
});
it('gets data with default value', function () {
$content = SlotContent::named('content', '<p>Content</p>', ['key' => 'value']);
expect($content->getData('key'))->toBe('value');
expect($content->getData('missing', 'default'))->toBe('default');
});
it('adds data to content', function () {
$content = SlotContent::named('content', '<p>Content</p>')
->withData(['key1' => 'value1', 'key2' => 'value2']);
expect($content->hasData())->toBeTrue();
expect($content->getData('key1'))->toBe('value1');
expect($content->getData('key2'))->toBe('value2');
});
it('updates content', function () {
$content = SlotContent::named('header', '<h1>Old</h1>')
->withContent('<h2>New</h2>');
expect($content->content)->toBe('<h2>New</h2>');
});
it('throws on empty slot name', function () {
expect(fn () => new SlotContent('', 'content'))->toThrow(\InvalidArgumentException::class);
});
it('converts to array for serialization', function () {
$componentId = ComponentId::generate();
$content = SlotContent::fromComponent('header', $componentId, '<h1>Header</h1>', ['key' => 'value']);
$array = $content->toArray();
expect($array['slot_name'])->toBe('header');
expect($array['content'])->toBe('<h1>Header</h1>');
expect($array['data'])->toBe(['key' => 'value']);
expect($array['component_id'])->toBe($componentId->toString());
expect($array['is_default'])->toBeFalse();
expect($array['has_data'])->toBeTrue();
});
it('creates from array', function () {
$array = [
'slot_name' => 'header',
'content' => '<h1>Header</h1>',
'data' => ['key' => 'value'],
];
$content = SlotContent::fromArray($array);
expect($content->slotName)->toBe('header');
expect($content->content)->toBe('<h1>Header</h1>');
expect($content->getData('key'))->toBe('value');
});
});
describe('SlotContext', function () {
it('creates empty context', function () {
$context = SlotContext::empty();
expect($context->isEmpty())->toBeTrue();
expect($context->count())->toBe(0);
});
it('creates context with data', function () {
$context = SlotContext::create(['key1' => 'value1', 'key2' => 'value2']);
expect($context->isEmpty())->toBeFalse();
expect($context->count())->toBe(2);
expect($context->get('key1'))->toBe('value1');
expect($context->get('key2'))->toBe('value2');
});
it('creates context with metadata', function () {
$context = SlotContext::create(
['data' => 'value'],
['timestamp' => 1234567890]
);
expect($context->getMetadata('timestamp'))->toBe(1234567890);
});
it('checks if key exists', function () {
$context = SlotContext::create(['key' => 'value']);
expect($context->has('key'))->toBeTrue();
expect($context->has('missing'))->toBeFalse();
});
it('gets value with default', function () {
$context = SlotContext::create(['key' => 'value']);
expect($context->get('key'))->toBe('value');
expect($context->get('missing', 'default'))->toBe('default');
});
it('adds data to context', function () {
$context = SlotContext::empty()
->withData(['key1' => 'value1'])
->withData(['key2' => 'value2']);
expect($context->count())->toBe(2);
expect($context->get('key1'))->toBe('value1');
expect($context->get('key2'))->toBe('value2');
});
it('sets single data value', function () {
$context = SlotContext::empty()
->with('key1', 'value1')
->with('key2', 'value2');
expect($context->count())->toBe(2);
});
it('removes data key', function () {
$context = SlotContext::create(['key1' => 'value1', 'key2' => 'value2'])
->without('key1');
expect($context->has('key1'))->toBeFalse();
expect($context->has('key2'))->toBeTrue();
});
it('gets all data keys', function () {
$context = SlotContext::create(['key1' => 'value1', 'key2' => 'value2']);
$keys = $context->keys();
expect($keys)->toBe(['key1', 'key2']);
});
it('merges contexts', function () {
$context1 = SlotContext::create(['key1' => 'value1']);
$context2 = SlotContext::create(['key2' => 'value2']);
$merged = $context1->merge($context2);
expect($merged->count())->toBe(2);
expect($merged->get('key1'))->toBe('value1');
expect($merged->get('key2'))->toBe('value2');
});
it('adds metadata to context', function () {
$context = SlotContext::empty()
->withMetadata(['meta1' => 'value1'])
->withMetadata(['meta2' => 'value2']);
expect($context->getMetadata('meta1'))->toBe('value1');
expect($context->getMetadata('meta2'))->toBe('value2');
});
it('converts to array for serialization', function () {
$context = SlotContext::create(
['key1' => 'value1', 'key2' => 'value2'],
['timestamp' => 1234567890]
);
$array = $context->toArray();
expect($array['data'])->toBe(['key1' => 'value1', 'key2' => 'value2']);
expect($array['metadata'])->toBe(['timestamp' => 1234567890]);
expect($array['is_empty'])->toBeFalse();
expect($array['keys'])->toBe(['key1', 'key2']);
expect($array['count'])->toBe(2);
});
it('converts to plain array', function () {
$context = SlotContext::create(['key1' => 'value1', 'key2' => 'value2']);
$plain = $context->toPlainArray();
expect($plain)->toBe(['key1' => 'value1', 'key2' => 'value2']);
});
it('creates from array', function () {
$array = [
'data' => ['key1' => 'value1'],
'metadata' => ['timestamp' => 1234567890],
];
$context = SlotContext::fromArray($array);
expect($context->get('key1'))->toBe('value1');
expect($context->getMetadata('timestamp'))->toBe(1234567890);
});
});

View File

@@ -0,0 +1,499 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Tracing\LiveComponentTracer;
use App\Framework\Telemetry\UnifiedTelemetryService;
use App\Framework\Telemetry\OperationHandle;
use App\Framework\Telemetry\Config\TelemetryConfig;
use App\Framework\Tracing\TraceSpan;
use App\Framework\Tracing\SpanStatus;
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
use App\Framework\CircuitBreaker\CircuitBreaker;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\SystemClock;
use App\Framework\Cache\Driver\InMemoryCache;
use App\Framework\Cache\GeneralCache;
use App\Framework\Serializer\Serializer;
use App\Framework\Logging\DefaultLogger;
describe('LiveComponentTracer', function () {
beforeEach(function () {
// Create telemetry service with enabled configuration
$this->config = new TelemetryConfig(
serviceName: 'test-service',
serviceVersion: '1.0.0',
environment: 'test',
enabled: true
);
// Create real instances for final classes
$this->clock = new SystemClock();
$serializer = Mockery::mock(Serializer::class);
$serializer->shouldReceive('serialize')->andReturnUsing(fn($val) => serialize($val));
$serializer->shouldReceive('unserialize')->andReturnUsing(fn($val) => unserialize($val));
$this->cache = new GeneralCache(new InMemoryCache(), $serializer);
// Create CircuitBreaker with test dependencies
$this->circuitBreaker = new CircuitBreaker($this->cache, $this->clock, null, null, null, 'test');
// Mock interfaces
$this->performanceCollector = Mockery::mock(PerformanceCollectorInterface::class);
// Configure mocks
$this->performanceCollector->shouldReceive('startTiming')->andReturnNull();
$this->performanceCollector->shouldReceive('endTiming')->andReturnNull();
// Create simple logger for tests
$this->logger = new DefaultLogger();
$this->telemetry = new UnifiedTelemetryService(
$this->performanceCollector,
$this->circuitBreaker,
$this->logger,
$this->clock,
$this->config
);
$this->tracer = new LiveComponentTracer($this->telemetry);
});
afterEach(function () {
Mockery::close();
});
describe('Resolve Operation Tracing', function () {
it('creates trace span for component resolution', function () {
$operation = $this->tracer->traceResolve('test-component', [
'data_provider' => 'UserProvider',
]);
expect($operation)->toBeInstanceOf(OperationHandle::class);
// End the operation
$operation->end('success');
});
it('includes component id in resolve attributes', function () {
$operation = $this->tracer->traceResolve('user-list', [
'initial_data' => ['page' => 1],
]);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
it('handles resolve operation errors gracefully', function () {
$operation = $this->tracer->traceResolve('failing-component');
expect($operation)->toBeInstanceOf(OperationHandle::class);
// End with error
$operation->fail('Component resolution failed');
});
});
describe('Render Operation Tracing', function () {
it('creates trace span for component rendering', function () {
$operation = $this->tracer->traceRender('test-component', [
'cached' => false,
'template' => 'components/user-card',
]);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
it('tracks render operation with view type', function () {
$operation = $this->tracer->traceRender('product-list', [
'items_count' => 50,
]);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
it('handles render failures with error status', function () {
$operation = $this->tracer->traceRender('broken-template');
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->fail('Template rendering failed');
});
});
describe('Handle Operation Tracing', function () {
it('creates trace span for action handling', function () {
$operation = $this->tracer->traceHandle(
'form-component',
'submitForm',
['validation' => 'passed']
);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
it('includes action name in handle attributes', function () {
$operation = $this->tracer->traceHandle(
'cart-component',
'addItem',
['item_id' => 123]
);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
it('tracks action execution time', function () {
$operation = $this->tracer->traceHandle('data-table', 'sortColumn');
expect($operation)->toBeInstanceOf(OperationHandle::class);
// Simulate some work
usleep(1000); // 1ms
$operation->end('success');
});
});
describe('Upload Operation Tracing', function () {
it('creates trace span for file upload', function () {
$operation = $this->tracer->traceUpload(
'file-uploader',
'upload-session-123',
['chunk' => 1, 'total_chunks' => 5]
);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
it('includes upload session id in attributes', function () {
$operation = $this->tracer->traceUpload(
'avatar-upload',
'session-abc-def',
['file_size' => 2048000]
);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
it('handles upload failures', function () {
$operation = $this->tracer->traceUpload('doc-uploader', 'session-xyz');
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->fail('Upload failed: file too large');
});
});
describe('Event Recording', function () {
it('records component lifecycle events', function () {
// Events are fire-and-forget, no return value
$this->tracer->recordEvent('mounted', 'test-component', [
'mount_time_ms' => 45.2,
]);
// Should not throw
expect(true)->toBeTrue();
});
it('records multiple events for component', function () {
$componentId = 'lifecycle-test';
$this->tracer->recordEvent('mounted', $componentId);
$this->tracer->recordEvent('updated', $componentId, ['updates' => 3]);
$this->tracer->recordEvent('destroyed', $componentId);
expect(true)->toBeTrue();
});
it('records custom component events', function () {
$this->tracer->recordEvent('form_submitted', 'contact-form', [
'fields_count' => 5,
'validation_passed' => true,
]);
expect(true)->toBeTrue();
});
});
describe('Metric Recording', function () {
it('records component metrics', function () {
$this->tracer->recordMetric('render_duration', 25.5, 'ms', [
'component_id' => 'test-component',
]);
expect(true)->toBeTrue();
});
it('records multiple metrics with different units', function () {
$this->tracer->recordMetric('cache_size', 1024, 'bytes');
$this->tracer->recordMetric('action_count', 15, 'count');
$this->tracer->recordMetric('response_time', 150.3, 'ms');
expect(true)->toBeTrue();
});
it('records metrics without unit', function () {
$this->tracer->recordMetric('items_rendered', 50.0);
expect(true)->toBeTrue();
});
});
describe('Traced Execution', function () {
it('executes callback within traced operation', function () {
$result = $this->tracer->trace(
'resolve',
'test-component',
fn() => ['data' => 'loaded'],
['source' => 'database']
);
expect($result)->toBe(['data' => 'loaded']);
});
it('executes render operation with trace', function () {
$html = $this->tracer->trace(
'render',
'user-card',
fn() => '<div>User Card</div>'
);
expect($html)->toBe('<div>User Card</div>');
});
it('executes handle operation with trace', function () {
$result = $this->tracer->trace(
'handle',
'form-component',
fn() => ['success' => true, 'message' => 'Form submitted']
);
expect($result['success'])->toBeTrue();
expect($result['message'])->toBe('Form submitted');
});
it('executes upload operation with trace', function () {
$uploadResult = $this->tracer->trace(
'upload',
'file-uploader',
fn() => ['uploaded' => true, 'file_id' => 123]
);
expect($uploadResult['uploaded'])->toBeTrue();
expect($uploadResult['file_id'])->toBe(123);
});
it('propagates exceptions from traced callbacks', function () {
expect(fn() => $this->tracer->trace(
'render',
'broken-component',
fn() => throw new \RuntimeException('Template not found')
))->toThrow(\RuntimeException::class, 'Template not found');
});
});
describe('Complex Tracing Scenarios', function () {
it('handles nested tracing operations', function () {
$result = $this->tracer->trace('resolve', 'parent-component', function() {
// Nested render operation
return $this->tracer->trace('render', 'child-component', function() {
// Nested action handling
return $this->tracer->trace('handle', 'nested-action', function() {
return ['nested' => true];
});
});
});
expect($result['nested'])->toBeTrue();
});
it('traces complete component lifecycle', function () {
$componentId = 'full-lifecycle';
// 1. Resolve
$resolveOp = $this->tracer->traceResolve($componentId);
$resolveOp->end('success');
// 2. Render
$renderOp = $this->tracer->traceRender($componentId);
$renderOp->end('success');
// 3. Handle action
$handleOp = $this->tracer->traceHandle($componentId, 'onClick');
$handleOp->end('success');
// 4. Re-render
$rerenderOp = $this->tracer->traceRender($componentId);
$rerenderOp->end('success');
expect(true)->toBeTrue();
});
it('traces concurrent operations on different components', function () {
$op1 = $this->tracer->traceRender('component-1');
$op2 = $this->tracer->traceRender('component-2');
$op3 = $this->tracer->traceHandle('component-3', 'action');
// End in different order
$op2->end('success');
$op1->end('success');
$op3->end('success');
expect(true)->toBeTrue();
});
});
describe('Telemetry Integration', function () {
it('checks if telemetry is enabled', function () {
expect($this->tracer->isEnabled())->toBeTrue();
});
it('provides operation stack for debugging', function () {
$this->tracer->trace('resolve', 'test-component', function() {
$stack = $this->tracer->getOperationStack();
expect($stack)->toBeString();
});
});
it('handles telemetry disabled gracefully', function () {
// Create tracer with disabled telemetry
$disabledConfig = new TelemetryConfig(
serviceName: 'test-service',
serviceVersion: '1.0.0',
environment: 'test',
enabled: false
);
$disabledTelemetry = new UnifiedTelemetryService(
$this->performanceCollector,
$this->circuitBreaker,
$this->logger,
$this->clock,
$disabledConfig
);
$disabledTracer = new LiveComponentTracer($disabledTelemetry);
expect($disabledTracer->isEnabled())->toBeFalse();
// Operations should still work without throwing
$op = $disabledTracer->traceResolve('test-component');
$op->end('success');
expect(true)->toBeTrue();
});
});
describe('Performance Characteristics', function () {
it('has minimal overhead for tracing', function () {
$start = microtime(true);
for ($i = 0; $i < 100; $i++) {
$op = $this->tracer->traceRender("component-{$i}");
$op->end('success');
}
$duration = (microtime(true) - $start) * 1000; // ms
// 100 operations should complete in reasonable time
expect($duration)->toBeLessThan(100.0); // < 1ms per operation
});
it('handles high-frequency event recording', function () {
$start = microtime(true);
for ($i = 0; $i < 1000; $i++) {
$this->tracer->recordEvent('click', 'button-component');
}
$duration = (microtime(true) - $start) * 1000; // ms
// 1000 events should record quickly
expect($duration)->toBeLessThan(200.0); // < 0.2ms per event
});
});
describe('Error Handling', function () {
it('records operation failures correctly', function () {
$operation = $this->tracer->traceHandle('error-component', 'failingAction');
// Simulate failure
$operation->fail('Action failed: validation error');
expect(true)->toBeTrue();
});
it('continues tracing after operation errors', function () {
// First operation fails
$op1 = $this->tracer->traceResolve('failing-component');
$op1->fail('Resolution failed');
// Subsequent operations should still work
$op2 = $this->tracer->traceRender('working-component');
$op2->end('success');
expect(true)->toBeTrue();
});
it('handles exceptions in traced callbacks', function () {
try {
$this->tracer->trace('handle', 'exception-component', function() {
throw new \RuntimeException('Business logic error');
});
} catch (\RuntimeException $e) {
expect($e->getMessage())->toBe('Business logic error');
}
// Tracing should still work after exception
$op = $this->tracer->traceRender('recovery-component');
$op->end('success');
expect(true)->toBeTrue();
});
});
describe('Attributes and Context', function () {
it('merges custom attributes with operation attributes', function () {
$operation = $this->tracer->traceResolve('test-component', [
'custom_attr1' => 'value1',
'custom_attr2' => 123,
]);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
it('preserves attribute types', function () {
$operation = $this->tracer->traceHandle('type-test', 'action', [
'string_attr' => 'text',
'int_attr' => 42,
'float_attr' => 3.14,
'bool_attr' => true,
'array_attr' => ['a', 'b', 'c'],
]);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
it('handles empty attributes gracefully', function () {
$operation = $this->tracer->traceRender('minimal-component', []);
expect($operation)->toBeInstanceOf(OperationHandle::class);
$operation->end('success');
});
});
});

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