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,103 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Batch\BatchOperation;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
describe('BatchOperation', function () {
it('creates operation from array', function () {
$data = [
'componentId' => 'counter:demo',
'method' => 'increment',
'params' => ['amount' => 5],
'fragments' => ['counter-display'],
'operationId' => 'op-1',
];
$operation = BatchOperation::fromArray($data);
expect($operation->componentId)->toBe('counter:demo');
expect($operation->method)->toBe('increment');
expect($operation->params)->toBe(['amount' => 5]);
expect($operation->fragments)->toBe(['counter-display']);
expect($operation->operationId)->toBe('op-1');
});
it('creates minimal operation', function () {
$operation = new BatchOperation(
componentId: 'stats:user',
method: 'refresh'
);
expect($operation->componentId)->toBe('stats:user');
expect($operation->method)->toBe('refresh');
expect($operation->params)->toBe([]);
expect($operation->fragments)->toBeNull();
expect($operation->operationId)->toBeNull();
});
it('throws on empty component id', function () {
expect(fn () => new BatchOperation('', 'method'))
->toThrow(InvalidArgumentException::class, 'Component ID cannot be empty');
});
it('throws on empty method', function () {
expect(fn () => new BatchOperation('counter:demo', ''))
->toThrow(InvalidArgumentException::class, 'Method cannot be empty');
});
it('converts params to ActionParameters', function () {
$operation = new BatchOperation(
componentId: 'counter:demo',
method: 'increment',
params: ['amount' => 5, 'step' => 2]
);
$actionParams = $operation->getActionParameters();
expect($actionParams)->toBeInstanceOf(ActionParameters::class);
expect($actionParams->toArray())->toBe(['amount' => 5, 'step' => 2]);
});
it('checks if has fragments', function () {
$withFragments = new BatchOperation('counter:demo', 'increment', fragments: ['display']);
expect($withFragments->hasFragments())->toBeTrue();
$withoutFragments = new BatchOperation('counter:demo', 'increment');
expect($withoutFragments->hasFragments())->toBeFalse();
$emptyFragments = new BatchOperation('counter:demo', 'increment', fragments: []);
expect($emptyFragments->hasFragments())->toBeFalse();
});
it('gets fragments', function () {
$operation = new BatchOperation(
componentId: 'counter:demo',
method: 'increment',
fragments: ['counter-display', 'counter-controls']
);
expect($operation->getFragments())->toBe(['counter-display', 'counter-controls']);
});
it('converts to array', function () {
$operation = new BatchOperation(
componentId: 'counter:demo',
method: 'increment',
params: ['amount' => 5],
fragments: ['display'],
operationId: 'op-1'
);
$array = $operation->toArray();
expect($array)->toBe([
'componentId' => 'counter:demo',
'method' => 'increment',
'params' => ['amount' => 5],
'fragments' => ['display'],
'operationId' => 'op-1',
]);
});
});

View File

@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Batch\BatchOperation;
use App\Framework\LiveComponents\Batch\BatchProcessor;
use App\Framework\LiveComponents\Batch\BatchRequest;
use App\Framework\LiveComponents\ComponentRegistry;
use App\Framework\LiveComponents\Contracts\LiveComponent;
use App\Framework\LiveComponents\LiveComponentHandler;
use App\Framework\LiveComponents\Rendering\FragmentRenderer;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
use App\Framework\View\Rendering\FragmentCollection;
describe('BatchProcessor', function () {
beforeEach(function () {
$this->componentRegistry = Mockery::mock(ComponentRegistry::class);
$this->handler = Mockery::mock(LiveComponentHandler::class);
$this->fragmentRenderer = Mockery::mock(FragmentRenderer::class);
$this->processor = new BatchProcessor(
$this->componentRegistry,
$this->handler,
$this->fragmentRenderer
);
});
afterEach(function () {
Mockery::close();
});
it('processes single operation successfully', function () {
$operation = new BatchOperation('counter:demo', 'increment', ['amount' => 5]);
$request = new BatchRequest($operation);
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('counter:demo'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['count' => 5]));
$this->componentRegistry
->shouldReceive('resolve')
->once()
->andReturn($component);
$update = new ComponentUpdate(
component: $component,
state: ComponentData::fromArray(['count' => 10]),
events: [['type' => 'incremented']]
);
$this->handler
->shouldReceive('handleAction')
->once()
->andReturn($update);
$this->componentRegistry
->shouldReceive('render')
->once()
->with($component)
->andReturn('<div>Counter: 10</div>');
$response = $this->processor->process($request);
expect($response->totalOperations)->toBe(1);
expect($response->successCount)->toBe(1);
expect($response->failureCount)->toBe(0);
expect($response->results[0]->success)->toBeTrue();
expect($response->results[0]->html)->toBe('<div>Counter: 10</div>');
expect($response->results[0]->state)->toBe(['count' => 10]);
expect($response->results[0]->events)->toBe([['type' => 'incremented']]);
});
it('processes multiple operations successfully', function () {
$op1 = new BatchOperation('counter:demo', 'increment');
$op2 = new BatchOperation('stats:user', 'refresh');
$request = new BatchRequest($op1, $op2);
$component1 = Mockery::mock(LiveComponent::class);
$component1->shouldReceive('getId')->andReturn(ComponentId::fromString('counter:demo'));
$component1->shouldReceive('getData')->andReturn(ComponentData::fromArray(['count' => 1]));
$component2 = Mockery::mock(LiveComponent::class);
$component2->shouldReceive('getId')->andReturn(ComponentId::fromString('stats:user'));
$component2->shouldReceive('getData')->andReturn(ComponentData::fromArray(['views' => 100]));
$this->componentRegistry
->shouldReceive('resolve')
->twice()
->andReturn($component1, $component2);
$update1 = new ComponentUpdate(
component: $component1,
state: ComponentData::fromArray(['count' => 2]),
events: []
);
$update2 = new ComponentUpdate(
component: $component2,
state: ComponentData::fromArray(['views' => 101]),
events: []
);
$this->handler
->shouldReceive('handleAction')
->twice()
->andReturn($update1, $update2);
$this->componentRegistry
->shouldReceive('render')
->twice()
->andReturn('<div>Counter: 2</div>', '<div>Views: 101</div>');
$response = $this->processor->process($request);
expect($response->totalOperations)->toBe(2);
expect($response->successCount)->toBe(2);
expect($response->failureCount)->toBe(0);
});
it('handles operation failure with error isolation', function () {
$op1 = new BatchOperation('counter:demo', 'increment');
$op2 = new BatchOperation('invalid:component', 'action');
$op3 = new BatchOperation('stats:user', 'refresh');
$request = new BatchRequest($op1, $op2, $op3);
$component1 = Mockery::mock(LiveComponent::class);
$component1->shouldReceive('getId')->andReturn(ComponentId::fromString('counter:demo'));
$component1->shouldReceive('getData')->andReturn(ComponentData::fromArray(['count' => 1]));
$component3 = Mockery::mock(LiveComponent::class);
$component3->shouldReceive('getId')->andReturn(ComponentId::fromString('stats:user'));
$component3->shouldReceive('getData')->andReturn(ComponentData::fromArray(['views' => 100]));
$this->componentRegistry
->shouldReceive('resolve')
->times(3)
->andReturnUsing(function ($id) use ($component1, $component3) {
if ($id->toString() === 'invalid:component') {
throw new InvalidArgumentException('Unknown component: invalid:component');
}
return $id->toString() === 'counter:demo' ? $component1 : $component3;
});
$update1 = new ComponentUpdate(
component: $component1,
state: ComponentData::fromArray(['count' => 2]),
events: []
);
$update3 = new ComponentUpdate(
component: $component3,
state: ComponentData::fromArray(['views' => 101]),
events: []
);
$this->handler
->shouldReceive('handleAction')
->twice()
->andReturn($update1, $update3);
$this->componentRegistry
->shouldReceive('render')
->twice()
->andReturn('<div>Counter: 2</div>', '<div>Views: 101</div>');
$response = $this->processor->process($request);
expect($response->totalOperations)->toBe(3);
expect($response->successCount)->toBe(2);
expect($response->failureCount)->toBe(1);
// First operation succeeds
expect($response->results[0]->success)->toBeTrue();
// Second operation fails
expect($response->results[1]->success)->toBeFalse();
expect($response->results[1]->error)->toContain('Unknown component');
expect($response->results[1]->errorCode)->toBe('COMPONENT_NOT_FOUND');
// Third operation succeeds (error isolation works)
expect($response->results[2]->success)->toBeTrue();
});
it('processes fragment-based operation', function () {
$operation = new BatchOperation(
componentId: 'counter:demo',
method: 'increment',
fragments: ['counter-display']
);
$request = new BatchRequest($operation);
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('counter:demo'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['count' => 5]));
$this->componentRegistry
->shouldReceive('resolve')
->once()
->andReturn($component);
$update = new ComponentUpdate(
component: $component,
state: ComponentData::fromArray(['count' => 10]),
events: []
);
$this->handler
->shouldReceive('handleAction')
->once()
->andReturn($update);
$fragmentCollection = FragmentCollection::fromArray([
'counter-display' => '<span>10</span>',
]);
$this->fragmentRenderer
->shouldReceive('renderFragments')
->once()
->with($component, ['counter-display'])
->andReturn($fragmentCollection);
$response = $this->processor->process($request);
expect($response->successCount)->toBe(1);
expect($response->results[0]->fragments)->toBe(['counter-display' => '<span>10</span>']);
expect($response->results[0]->html)->toBeNull();
});
it('preserves operation ids in results', function () {
$op1 = new BatchOperation('counter:demo', 'increment', operationId: 'op-1');
$op2 = new BatchOperation('stats:user', 'refresh', operationId: 'op-2');
$request = new BatchRequest($op1, $op2);
$component1 = Mockery::mock(LiveComponent::class);
$component1->shouldReceive('getId')->andReturn(ComponentId::fromString('counter:demo'));
$component1->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
$component2 = Mockery::mock(LiveComponent::class);
$component2->shouldReceive('getId')->andReturn(ComponentId::fromString('stats:user'));
$component2->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
$this->componentRegistry
->shouldReceive('resolve')
->twice()
->andReturn($component1, $component2);
$this->handler
->shouldReceive('handleAction')
->twice()
->andReturn(
new ComponentUpdate($component1, ComponentData::fromArray([]), []),
new ComponentUpdate($component2, ComponentData::fromArray([]), [])
);
$this->componentRegistry
->shouldReceive('render')
->twice()
->andReturn('<div>1</div>', '<div>2</div>');
$response = $this->processor->process($request);
expect($response->results[0]->operationId)->toBe('op-1');
expect($response->results[1]->operationId)->toBe('op-2');
});
});

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Batch\BatchOperation;
use App\Framework\LiveComponents\Batch\BatchRequest;
describe('BatchRequest', function () {
it('creates request with variadic constructor', function () {
$op1 = new BatchOperation('counter:demo', 'increment');
$op2 = new BatchOperation('stats:user', 'refresh');
$request = new BatchRequest($op1, $op2);
expect($request->operations)->toHaveCount(2);
expect($request->operations[0])->toBe($op1);
expect($request->operations[1])->toBe($op2);
});
it('creates request from array', function () {
$data = [
'operations' => [
[
'componentId' => 'counter:demo',
'method' => 'increment',
'params' => ['amount' => 5],
],
[
'componentId' => 'stats:user',
'method' => 'refresh',
],
],
];
$request = BatchRequest::fromArray($data);
expect($request->operations)->toHaveCount(2);
expect($request->operations[0]->componentId)->toBe('counter:demo');
expect($request->operations[1]->componentId)->toBe('stats:user');
});
it('throws on empty operations', function () {
expect(fn () => new BatchRequest())
->toThrow(InvalidArgumentException::class, 'Batch request must contain at least one operation');
});
it('throws on too many operations', function () {
$operations = [];
for ($i = 0; $i < 51; $i++) {
$operations[] = new BatchOperation("component:$i", 'method');
}
expect(fn () => new BatchRequest(...$operations))
->toThrow(InvalidArgumentException::class, 'Batch request cannot exceed 50 operations');
});
it('allows maximum 50 operations', function () {
$operations = [];
for ($i = 0; $i < 50; $i++) {
$operations[] = new BatchOperation("component:$i", 'method');
}
$request = new BatchRequest(...$operations);
expect($request->operations)->toHaveCount(50);
});
it('gets operations', function () {
$op1 = new BatchOperation('counter:demo', 'increment');
$op2 = new BatchOperation('stats:user', 'refresh');
$request = new BatchRequest($op1, $op2);
expect($request->getOperations())->toBe([$op1, $op2]);
});
it('counts operations', function () {
$op1 = new BatchOperation('counter:demo', 'increment');
$op2 = new BatchOperation('stats:user', 'refresh');
$op3 = new BatchOperation('form:contact', 'submit');
$request = new BatchRequest($op1, $op2, $op3);
expect($request->count())->toBe(3);
});
});

View File

@@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Batch\BatchResponse;
use App\Framework\LiveComponents\Batch\BatchResult;
describe('BatchResponse', function () {
it('creates response with variadic constructor', function () {
$result1 = BatchResult::success(operationId: 'op-1', html: '<div>Test</div>');
$result2 = BatchResult::failure(error: 'Failed', operationId: 'op-2');
$response = new BatchResponse($result1, $result2);
expect($response->results)->toHaveCount(2);
expect($response->totalOperations)->toBe(2);
expect($response->successCount)->toBe(1);
expect($response->failureCount)->toBe(1);
});
it('calculates statistics correctly', function () {
$results = [
BatchResult::success(operationId: 'op-1'),
BatchResult::success(operationId: 'op-2'),
BatchResult::success(operationId: 'op-3'),
BatchResult::failure(error: 'Error', operationId: 'op-4'),
];
$response = new BatchResponse(...$results);
expect($response->totalOperations)->toBe(4);
expect($response->successCount)->toBe(3);
expect($response->failureCount)->toBe(1);
});
it('creates from results array', function () {
$results = [
BatchResult::success(operationId: 'op-1'),
BatchResult::success(operationId: 'op-2'),
];
$response = BatchResponse::fromResults($results);
expect($response->results)->toHaveCount(2);
expect($response->totalOperations)->toBe(2);
});
it('gets result by index', function () {
$result1 = BatchResult::success(operationId: 'op-1');
$result2 = BatchResult::success(operationId: 'op-2');
$response = new BatchResponse($result1, $result2);
expect($response->getResult(0))->toBe($result1);
expect($response->getResult(1))->toBe($result2);
expect($response->getResult(2))->toBeNull();
});
it('gets all results', function () {
$result1 = BatchResult::success(operationId: 'op-1');
$result2 = BatchResult::failure(error: 'Error', operationId: 'op-2');
$response = new BatchResponse($result1, $result2);
expect($response->getResults())->toBe([$result1, $result2]);
});
it('gets successful results only', function () {
$success1 = BatchResult::success(operationId: 'op-1');
$success2 = BatchResult::success(operationId: 'op-2');
$failure = BatchResult::failure(error: 'Error', operationId: 'op-3');
$response = new BatchResponse($success1, $failure, $success2);
$successfulResults = $response->getSuccessfulResults();
expect($successfulResults)->toHaveCount(2);
expect($successfulResults)->toContain($success1);
expect($successfulResults)->toContain($success2);
expect($successfulResults)->not->toContain($failure);
});
it('gets failed results only', function () {
$success = BatchResult::success(operationId: 'op-1');
$failure1 = BatchResult::failure(error: 'Error 1', operationId: 'op-2');
$failure2 = BatchResult::failure(error: 'Error 2', operationId: 'op-3');
$response = new BatchResponse($success, $failure1, $failure2);
$failedResults = $response->getFailedResults();
expect($failedResults)->toHaveCount(2);
expect($failedResults)->toContain($failure1);
expect($failedResults)->toContain($failure2);
expect($failedResults)->not->toContain($success);
});
it('checks if full success', function () {
$allSuccess = new BatchResponse(
BatchResult::success(operationId: 'op-1'),
BatchResult::success(operationId: 'op-2')
);
expect($allSuccess->isFullSuccess())->toBeTrue();
$partial = new BatchResponse(
BatchResult::success(operationId: 'op-1'),
BatchResult::failure(error: 'Error', operationId: 'op-2')
);
expect($partial->isFullSuccess())->toBeFalse();
});
it('checks if full failure', function () {
$allFailed = new BatchResponse(
BatchResult::failure(error: 'Error 1', operationId: 'op-1'),
BatchResult::failure(error: 'Error 2', operationId: 'op-2')
);
expect($allFailed->isFullFailure())->toBeTrue();
$partial = new BatchResponse(
BatchResult::success(operationId: 'op-1'),
BatchResult::failure(error: 'Error', operationId: 'op-2')
);
expect($partial->isFullFailure())->toBeFalse();
});
it('checks if has partial failure', function () {
$partial = new BatchResponse(
BatchResult::success(operationId: 'op-1'),
BatchResult::success(operationId: 'op-2'),
BatchResult::failure(error: 'Error', operationId: 'op-3')
);
expect($partial->hasPartialFailure())->toBeTrue();
$allSuccess = new BatchResponse(
BatchResult::success(operationId: 'op-1'),
BatchResult::success(operationId: 'op-2')
);
expect($allSuccess->hasPartialFailure())->toBeFalse();
$allFailed = new BatchResponse(
BatchResult::failure(error: 'Error 1', operationId: 'op-1'),
BatchResult::failure(error: 'Error 2', operationId: 'op-2')
);
expect($allFailed->hasPartialFailure())->toBeFalse();
});
it('converts to array', function () {
$response = new BatchResponse(
BatchResult::success(operationId: 'op-1', html: '<div>Test</div>', state: ['value' => 1]),
BatchResult::failure(error: 'Error', errorCode: 'TEST_ERROR', operationId: 'op-2')
);
$array = $response->toArray();
expect($array)->toHaveKey('results');
expect($array)->toHaveKey('total_operations');
expect($array)->toHaveKey('success_count');
expect($array)->toHaveKey('failure_count');
expect($array['results'])->toHaveCount(2);
expect($array['total_operations'])->toBe(2);
expect($array['success_count'])->toBe(1);
expect($array['failure_count'])->toBe(1);
expect($array['results'][0]['success'])->toBeTrue();
expect($array['results'][1]['success'])->toBeFalse();
});
});

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Batch\BatchResult;
describe('BatchResult', function () {
it('creates success result with html', function () {
$result = BatchResult::success(
operationId: 'op-1',
html: '<div>Counter: 5</div>',
state: ['count' => 5],
events: [['type' => 'counter:incremented']]
);
expect($result->success)->toBeTrue();
expect($result->operationId)->toBe('op-1');
expect($result->html)->toBe('<div>Counter: 5</div>');
expect($result->state)->toBe(['count' => 5]);
expect($result->events)->toBe([['type' => 'counter:incremented']]);
expect($result->fragments)->toBeNull();
expect($result->error)->toBeNull();
expect($result->errorCode)->toBeNull();
});
it('creates success result with fragments', function () {
$result = BatchResult::success(
operationId: 'op-1',
fragments: ['counter-display' => '<span>5</span>'],
state: ['count' => 5]
);
expect($result->success)->toBeTrue();
expect($result->fragments)->toBe(['counter-display' => '<span>5</span>']);
expect($result->html)->toBeNull();
});
it('creates failure result', function () {
$result = BatchResult::failure(
error: 'Component not found',
errorCode: 'COMPONENT_NOT_FOUND',
operationId: 'op-1'
);
expect($result->success)->toBeFalse();
expect($result->error)->toBe('Component not found');
expect($result->errorCode)->toBe('COMPONENT_NOT_FOUND');
expect($result->operationId)->toBe('op-1');
expect($result->html)->toBeNull();
expect($result->fragments)->toBeNull();
expect($result->state)->toBeNull();
});
it('converts success to array', function () {
$result = BatchResult::success(
operationId: 'op-1',
html: '<div>Test</div>',
state: ['value' => 'test'],
events: []
);
$array = $result->toArray();
expect($array)->toBe([
'success' => true,
'operationId' => 'op-1',
'html' => '<div>Test</div>',
'state' => ['value' => 'test'],
'events' => [],
]);
});
it('converts failure to array', function () {
$result = BatchResult::failure(
error: 'Action not allowed',
errorCode: 'ACTION_NOT_ALLOWED',
operationId: 'op-2'
);
$array = $result->toArray();
expect($array)->toBe([
'success' => false,
'operationId' => 'op-2',
'error' => 'Action not allowed',
'errorCode' => 'ACTION_NOT_ALLOWED',
]);
});
it('includes fragments in array when present', function () {
$result = BatchResult::success(
operationId: 'op-1',
fragments: ['header' => '<h1>Title</h1>'],
state: ['title' => 'Title']
);
$array = $result->toArray();
expect($array['fragments'])->toBe(['header' => '<h1>Title</h1>']);
expect($array)->not->toHaveKey('html');
});
it('creates minimal success result', function () {
$result = BatchResult::success();
expect($result->success)->toBeTrue();
expect($result->operationId)->toBeNull();
expect($result->html)->toBeNull();
expect($result->state)->toBeNull();
expect($result->events)->toBe([]);
});
});

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Cache\CacheMetricsCollector;
use App\Framework\LiveComponents\Cache\CacheType;
describe('CacheMetricsCollector', function () {
it('initializes with empty metrics for all cache types', function () {
$collector = new CacheMetricsCollector();
$stateMetrics = $collector->getMetrics(CacheType::STATE);
$slotMetrics = $collector->getMetrics(CacheType::SLOT);
$templateMetrics = $collector->getMetrics(CacheType::TEMPLATE);
expect($stateMetrics->hits)->toBe(0)
->and($slotMetrics->hits)->toBe(0)
->and($templateMetrics->hits)->toBe(0);
});
it('records hits for specific cache type', function () {
$collector = new CacheMetricsCollector();
$collector->recordHit(CacheType::STATE, 0.5);
$collector->recordHit(CacheType::STATE, 0.6);
$metrics = $collector->getMetrics(CacheType::STATE);
expect($metrics->hits)->toBe(2)
->and($metrics->misses)->toBe(0);
});
it('records misses for specific cache type', function () {
$collector = new CacheMetricsCollector();
$collector->recordMiss(CacheType::SLOT, 1.0);
$collector->recordMiss(CacheType::SLOT, 1.2);
$metrics = $collector->getMetrics(CacheType::SLOT);
expect($metrics->hits)->toBe(0)
->and($metrics->misses)->toBe(2);
});
it('records invalidations for specific cache type', function () {
$collector = new CacheMetricsCollector();
$collector->recordInvalidation(CacheType::TEMPLATE);
$collector->recordInvalidation(CacheType::TEMPLATE);
$metrics = $collector->getMetrics(CacheType::TEMPLATE);
expect($metrics->invalidations)->toBe(2);
});
it('updates cache size for specific cache type', function () {
$collector = new CacheMetricsCollector();
$collector->updateSize(CacheType::STATE, 150);
$metrics = $collector->getMetrics(CacheType::STATE);
expect($metrics->totalSize)->toBe(150);
});
it('maintains separate metrics for different cache types', function () {
$collector = new CacheMetricsCollector();
$collector->recordHit(CacheType::STATE, 0.5);
$collector->recordMiss(CacheType::SLOT, 1.0);
$collector->recordInvalidation(CacheType::TEMPLATE);
expect($collector->getMetrics(CacheType::STATE)->hits)->toBe(1)
->and($collector->getMetrics(CacheType::SLOT)->misses)->toBe(1)
->and($collector->getMetrics(CacheType::TEMPLATE)->invalidations)->toBe(1);
});
it('returns all metrics', function () {
$collector = new CacheMetricsCollector();
$collector->recordHit(CacheType::STATE, 0.5);
$collector->recordHit(CacheType::SLOT, 0.6);
$allMetrics = $collector->getAllMetrics();
expect($allMetrics)->toBeArray()
->and($allMetrics)->toHaveKey('state')
->and($allMetrics)->toHaveKey('slot')
->and($allMetrics)->toHaveKey('template');
});
it('calculates aggregate metrics across all caches', function () {
$collector = new CacheMetricsCollector();
// State: 2 hits
$collector->recordHit(CacheType::STATE, 0.5);
$collector->recordHit(CacheType::STATE, 0.6);
// Slot: 1 hit, 1 miss
$collector->recordHit(CacheType::SLOT, 0.7);
$collector->recordMiss(CacheType::SLOT, 1.0);
// Template: 1 miss
$collector->recordMiss(CacheType::TEMPLATE, 1.2);
$aggregate = $collector->getAggregateMetrics();
expect($aggregate->cacheType)->toBe(CacheType::MERGED)
->and($aggregate->hits)->toBe(3)
->and($aggregate->misses)->toBe(2)
->and($aggregate->getTotalOperations())->toBe(5);
});
it('generates comprehensive summary', function () {
$collector = new CacheMetricsCollector();
$collector->recordHit(CacheType::STATE, 0.5);
$collector->recordMiss(CacheType::SLOT, 1.0);
$summary = $collector->getSummary();
expect($summary)->toHaveKey('overall')
->and($summary)->toHaveKey('by_type')
->and($summary)->toHaveKey('performance_assessment')
->and($summary['by_type'])->toHaveKey('state')
->and($summary['by_type'])->toHaveKey('slot')
->and($summary['by_type'])->toHaveKey('template');
});
it('assesses performance against targets', function () {
$collector = new CacheMetricsCollector();
// State: 80% hit rate (target: 70%)
for ($i = 0; $i < 8; $i++) {
$collector->recordHit(CacheType::STATE, 0.5);
}
for ($i = 0; $i < 2; $i++) {
$collector->recordMiss(CacheType::STATE, 1.0);
}
$assessment = $collector->assessPerformance();
expect($assessment['state_cache']['meets_target'])->toBeTrue()
->and($assessment['state_cache']['grade'])->toBe('B');
});
it('detects performance issues when targets not met', function () {
$collector = new CacheMetricsCollector();
// State: 50% hit rate (below 70% target)
$collector->recordHit(CacheType::STATE, 0.5);
$collector->recordMiss(CacheType::STATE, 1.0);
expect($collector->hasPerformanceIssues())->toBeTrue();
});
it('generates performance warnings for underperforming caches', function () {
$collector = new CacheMetricsCollector();
// State: 50% hit rate (below 70% target)
$collector->recordHit(CacheType::STATE, 0.5);
$collector->recordMiss(CacheType::STATE, 1.0);
$warnings = $collector->getPerformanceWarnings();
expect($warnings)->toBeArray()
->and($warnings)->not->toBeEmpty()
->and($warnings[0])->toContain('State cache hit rate');
});
it('returns no warnings when all caches meet targets', function () {
$collector = new CacheMetricsCollector();
// State: 90% hit rate (exceeds 70% target)
for ($i = 0; $i < 9; $i++) {
$collector->recordHit(CacheType::STATE, 0.5);
}
$collector->recordMiss(CacheType::STATE, 1.0);
// Slot: 80% hit rate (exceeds 60% target)
for ($i = 0; $i < 8; $i++) {
$collector->recordHit(CacheType::SLOT, 0.5);
}
for ($i = 0; $i < 2; $i++) {
$collector->recordMiss(CacheType::SLOT, 1.0);
}
// Template: 90% hit rate (exceeds 80% target)
for ($i = 0; $i < 9; $i++) {
$collector->recordHit(CacheType::TEMPLATE, 0.5);
}
$collector->recordMiss(CacheType::TEMPLATE, 1.0);
expect($collector->hasPerformanceIssues())->toBeFalse()
->and($collector->getPerformanceWarnings())->toBeEmpty();
});
it('exports metrics with timestamp', function () {
$collector = new CacheMetricsCollector();
$collector->recordHit(CacheType::STATE, 0.5);
$export = $collector->export();
expect($export)->toHaveKey('timestamp')
->and($export)->toHaveKey('metrics')
->and($export['timestamp'])->toBeInt();
});
it('resets all metrics', function () {
$collector = new CacheMetricsCollector();
$collector->recordHit(CacheType::STATE, 0.5);
$collector->recordHit(CacheType::SLOT, 0.6);
$collector->recordHit(CacheType::TEMPLATE, 0.7);
$collector->reset();
expect($collector->getMetrics(CacheType::STATE)->hits)->toBe(0)
->and($collector->getMetrics(CacheType::SLOT)->hits)->toBe(0)
->and($collector->getMetrics(CacheType::TEMPLATE)->hits)->toBe(0);
});
});

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\LiveComponents\Cache\CacheMetrics;
use App\Framework\LiveComponents\Cache\CacheType;
describe('CacheMetrics', function () {
it('creates empty metrics with zero values', function () {
$metrics = CacheMetrics::empty(CacheType::STATE);
expect($metrics->cacheType)->toBe(CacheType::STATE)
->and($metrics->hits)->toBe(0)
->and($metrics->misses)->toBe(0)
->and($metrics->hitRate->getValue())->toBe(0.0)
->and($metrics->missRate->getValue())->toBe(0.0)
->and($metrics->getTotalOperations())->toBe(0);
});
it('records cache hit and updates metrics', function () {
$metrics = CacheMetrics::empty(CacheType::STATE);
$updated = $metrics->withHit(0.5);
expect($updated->hits)->toBe(1)
->and($updated->misses)->toBe(0)
->and($updated->hitRate->getValue())->toBe(100.0)
->and($updated->missRate->getValue())->toBe(0.0)
->and($updated->averageLookupTimeMs)->toBe(0.5);
});
it('records cache miss and updates metrics', function () {
$metrics = CacheMetrics::empty(CacheType::STATE);
$updated = $metrics->withMiss(1.2);
expect($updated->hits)->toBe(0)
->and($updated->misses)->toBe(1)
->and($updated->hitRate->getValue())->toBe(0.0)
->and($updated->missRate->getValue())->toBe(100.0)
->and($updated->averageLookupTimeMs)->toBe(1.2);
});
it('calculates correct hit rate with mixed hits and misses', function () {
$metrics = CacheMetrics::empty(CacheType::STATE)
->withHit(0.5)
->withHit(0.6)
->withHit(0.4)
->withMiss(1.0);
expect($metrics->hits)->toBe(3)
->and($metrics->misses)->toBe(1)
->and($metrics->hitRate->getValue())->toBe(75.0)
->and($metrics->missRate->getValue())->toBe(25.0)
->and($metrics->getTotalOperations())->toBe(4);
});
it('calculates weighted average lookup time', function () {
$metrics = CacheMetrics::empty(CacheType::STATE)
->withHit(0.5) // Avg: 0.5
->withHit(1.5); // Avg: (0.5 + 1.5) / 2 = 1.0
expect($metrics->averageLookupTimeMs)->toBe(1.0);
});
it('records cache invalidations', function () {
$metrics = CacheMetrics::empty(CacheType::STATE)
->withInvalidation()
->withInvalidation();
expect($metrics->invalidations)->toBe(2);
});
it('updates cache size', function () {
$metrics = CacheMetrics::empty(CacheType::STATE)
->withSize(150);
expect($metrics->totalSize)->toBe(150);
});
it('checks performance target with Percentage', function () {
$metrics = CacheMetrics::empty(CacheType::STATE)
->withHit(0.5)
->withHit(0.6)
->withHit(0.7)
->withMiss(1.0); // 75% hit rate
$target = Percentage::from(70.0);
expect($metrics->meetsPerformanceTarget($target))->toBeTrue();
});
it('returns correct performance grade', function () {
expect(
CacheMetrics::empty(CacheType::STATE)
->withHit(0.5)->withHit(0.5)->withHit(0.5)->withHit(0.5)
->withHit(0.5)->withHit(0.5)->withHit(0.5)->withHit(0.5)
->withHit(0.5)->withMiss(1.0) // 90% hit rate
->getPerformanceGrade()
)->toBe('A');
expect(
CacheMetrics::empty(CacheType::STATE)
->withHit(0.5)->withHit(0.5)->withHit(0.5)->withHit(0.5)
->withMiss(1.0)->withMiss(1.0) // 66.67% hit rate
->getPerformanceGrade()
)->toBe('D');
});
it('converts to array for reporting', function () {
$metrics = CacheMetrics::empty(CacheType::STATE)
->withHit(0.5)
->withMiss(1.0);
$array = $metrics->toArray();
expect($array)->toHaveKey('cache_type')
->and($array)->toHaveKey('hits')
->and($array)->toHaveKey('misses')
->and($array)->toHaveKey('hit_rate')
->and($array)->toHaveKey('miss_rate')
->and($array)->toHaveKey('average_lookup_time_ms')
->and($array)->toHaveKey('performance_grade')
->and($array['cache_type'])->toBe('state')
->and($array['hits'])->toBe(1)
->and($array['misses'])->toBe(1);
});
it('merges metrics from multiple sources', function () {
$metrics1 = CacheMetrics::empty(CacheType::STATE)
->withHit(0.5)
->withHit(0.6);
$metrics2 = CacheMetrics::empty(CacheType::SLOT)
->withHit(0.7)
->withMiss(1.0);
$merged = CacheMetrics::merge($metrics1, $metrics2);
expect($merged->cacheType)->toBe(CacheType::MERGED)
->and($merged->hits)->toBe(3)
->and($merged->misses)->toBe(1)
->and($merged->hitRate->getValue())->toBe(75.0);
});
it('handles empty merge gracefully', function () {
$merged = CacheMetrics::merge();
expect($merged->cacheType)->toBe(CacheType::MERGED)
->and($merged->hits)->toBe(0)
->and($merged->misses)->toBe(0);
});
it('preserves immutability when recording operations', function () {
$original = CacheMetrics::empty(CacheType::STATE);
$updated = $original->withHit(0.5);
expect($original->hits)->toBe(0)
->and($updated->hits)->toBe(1)
->and($original)->not->toBe($updated);
});
});

View File

@@ -0,0 +1,369 @@
<?php
declare(strict_types=1);
namespace Tests\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\UploadProgressTracker;
use App\Framework\LiveComponents\Services\UploadSessionIdGenerator;
use App\Framework\LiveComponents\Services\UploadSessionStore;
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
use App\Framework\LiveComponents\ValueObjects\UploadSessionId;
beforeEach(function () {
// Setup dependencies
$this->sessionIdGenerator = new UploadSessionIdGenerator();
$this->sessionStore = new UploadSessionStore();
$this->integrityValidator = new IntegrityValidator();
$this->chunkAssembler = new ChunkAssembler();
$this->fileStorage = new InMemoryStorage();
// Mock progress tracker (no SSE in tests)
$this->progressTracker = new class {
public function broadcastInitialized($session, $userId): void {}
public function broadcastChunkUploaded($session, $userId): void {}
public function broadcastCompleted($session, $userId): void {}
public function broadcastAborted($sessionId, $userId, $reason): void {}
};
$this->uploadManager = new ChunkedUploadManager(
$this->sessionIdGenerator,
$this->sessionStore,
$this->integrityValidator,
$this->chunkAssembler,
$this->fileStorage,
$this->progressTracker,
'/tmp/test-uploads'
);
});
test('initializes upload session successfully', function () {
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromKilobytes(1),
chunkSize: Byte::fromBytes(512)
);
expect($session->sessionId)->toBeInstanceOf(UploadSessionId::class);
expect($session->componentId)->toBe('test-uploader');
expect($session->fileName)->toBe('test-file.txt');
expect($session->totalSize->toBytes())->toBe(1024);
expect($session->totalChunks)->toBe(2); // 1024 bytes / 512 bytes = 2 chunks
expect($session->isComplete())->toBeFalse();
});
test('rejects invalid file size', function () {
expect(fn() => $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromBytes(0),
chunkSize: Byte::fromBytes(512)
))->toThrow(\InvalidArgumentException::class, 'Total size must be greater than zero');
});
test('rejects invalid chunk size', function () {
expect(fn() => $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromKilobytes(1),
chunkSize: Byte::fromBytes(0)
))->toThrow(\InvalidArgumentException::class, 'Chunk size must be greater than zero');
});
test('uploads chunk successfully', 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 first chunk
$updatedSession = $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunkData,
providedHash: $chunkHash
);
expect($updatedSession->getUploadedChunks())->toHaveCount(1);
expect($updatedSession->getProgress())->toBe(50.0); // 1 of 2 chunks
expect($updatedSession->isComplete())->toBeFalse();
});
test('rejects chunk with invalid hash', 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 with wrong hash
$chunkData = str_repeat('A', 512);
$wrongHash = ChunkHash::fromData('different data');
expect(fn() => $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunkData,
providedHash: $wrongHash
))->toThrow(\InvalidArgumentException::class, 'hash mismatch');
});
test('rejects invalid chunk index', function () {
// Initialize session
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512)
);
$chunkData = str_repeat('A', 512);
$chunkHash = ChunkHash::fromData($chunkData);
// Try to upload chunk with invalid index
expect(fn() => $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 99, // Out of bounds
chunkData: $chunkData,
providedHash: $chunkHash
))->toThrow(\InvalidArgumentException::class, 'Invalid chunk index');
});
test('completes upload after all chunks uploaded', function () {
// Initialize session
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512)
);
// Upload all chunks
$chunk1Data = str_repeat('A', 512);
$chunk1Hash = ChunkHash::fromData($chunk1Data);
$this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunk1Data,
providedHash: $chunk1Hash
);
$chunk2Data = str_repeat('B', 512);
$chunk2Hash = ChunkHash::fromData($chunk2Data);
$updatedSession = $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 1,
chunkData: $chunk2Data,
providedHash: $chunk2Hash
);
expect($updatedSession->isComplete())->toBeTrue();
expect($updatedSession->getProgress())->toBe(100.0);
// Complete upload
$targetPath = '/tmp/final-file.txt';
$completedSession = $this->uploadManager->completeUpload(
sessionId: $session->sessionId,
targetPath: $targetPath
);
expect($completedSession->completedAt)->not->toBeNull();
expect($this->fileStorage->exists($targetPath))->toBeTrue();
// Verify assembled file content
$assembledContent = $this->fileStorage->get($targetPath);
expect($assembledContent)->toBe($chunk1Data . $chunk2Data);
});
test('rejects completion when chunks are missing', function () {
// Initialize session
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512)
);
// Upload only first chunk (missing second chunk)
$chunkData = str_repeat('A', 512);
$chunkHash = ChunkHash::fromData($chunkData);
$this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunkData,
providedHash: $chunkHash
);
// Try to complete with missing chunks
expect(fn() => $this->uploadManager->completeUpload(
sessionId: $session->sessionId,
targetPath: '/tmp/final-file.txt'
))->toThrow(\InvalidArgumentException::class, 'Upload incomplete');
});
test('aborts upload and cleans up', function () {
// Initialize session
$session = $this->uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512)
);
// Upload one chunk
$chunkData = str_repeat('A', 512);
$chunkHash = ChunkHash::fromData($chunkData);
$this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: 0,
chunkData: $chunkData,
providedHash: $chunkHash
);
// Abort upload
$this->uploadManager->abortUpload(
sessionId: $session->sessionId,
reason: 'User cancelled'
);
// Verify session is deleted
expect($this->uploadManager->getStatus($session->sessionId))->toBeNull();
});
test('tracks progress correctly', 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)
);
expect($session->getProgress())->toBe(0.0);
// Upload chunks one by one
for ($i = 0; $i < 4; $i++) {
$chunkData = str_repeat(chr(65 + $i), 512);
$chunkHash = ChunkHash::fromData($chunkData);
$session = $this->uploadManager->uploadChunk(
sessionId: $session->sessionId,
chunkIndex: $i,
chunkData: $chunkData,
providedHash: $chunkHash
);
$expectedProgress = (($i + 1) / 4) * 100;
expect($session->getProgress())->toBe($expectedProgress);
}
expect($session->isComplete())->toBeTrue();
});
test('handles session not found', function () {
$nonExistentSessionId = UploadSessionId::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');
});
test('broadcasts progress with userId', function () {
$broadcastCalled = false;
// Replace progress tracker with capturing mock
$this->progressTracker = new class($broadcastCalled) {
public function __construct(private &$called) {}
public function broadcastInitialized($session, $userId): void {
if ($userId !== null) {
$this->called = true;
}
}
public function broadcastChunkUploaded($session, $userId): void {}
public function broadcastCompleted($session, $userId): void {}
public function broadcastAborted($sessionId, $userId, $reason): void {}
};
$uploadManager = new ChunkedUploadManager(
$this->sessionIdGenerator,
$this->sessionStore,
$this->integrityValidator,
$this->chunkAssembler,
$this->fileStorage,
$this->progressTracker,
'/tmp/test-uploads'
);
// Initialize with userId
$uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512),
userId: 'user-123'
);
expect($broadcastCalled)->toBeTrue();
});
test('skips SSE broadcast when userId is null', function () {
$broadcastCalled = false;
// Replace progress tracker with capturing mock
$this->progressTracker = new class($broadcastCalled) {
public function __construct(private &$called) {}
public function broadcastInitialized($session, $userId): void {
$this->called = true;
}
public function broadcastChunkUploaded($session, $userId): void {}
public function broadcastCompleted($session, $userId): void {}
public function broadcastAborted($sessionId, $userId, $reason): void {}
};
$uploadManager = new ChunkedUploadManager(
$this->sessionIdGenerator,
$this->sessionStore,
$this->integrityValidator,
$this->chunkAssembler,
$this->fileStorage,
$this->progressTracker,
'/tmp/test-uploads'
);
// Initialize without userId
$uploadManager->initializeUpload(
componentId: 'test-uploader',
fileName: 'test-file.txt',
totalSize: Byte::fromBytes(1024),
chunkSize: Byte::fromBytes(512),
userId: null // No SSE
);
expect($broadcastCalled)->toBeFalse();
});

View File

@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\LiveComponents\ComponentCacheManager;
use App\Framework\LiveComponents\Contracts\LiveComponent;
use App\Framework\LiveComponents\ValueObjects\CacheConfig;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
describe('ComponentCacheManager', function () {
beforeEach(function () {
$this->cache = Mockery::mock(Cache::class);
$this->cacheManager = new ComponentCacheManager($this->cache);
});
afterEach(function () {
Mockery::close();
});
it('caches component with basic config', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('stats:user-123'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['views' => 100]));
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5),
varyBy: [],
staleWhileRevalidate: false
);
$html = '<div class="stats">Views: 100</div>';
$this->cache
->shouldReceive('set')
->once()
->with(Mockery::on(function (CacheItem $item) use ($html) {
return $item->value === $html
&& $item->ttl->toSeconds() === 300;
}))
->andReturn(true);
$result = $this->cacheManager->set($component, $html, $config);
expect($result)->toBeTrue();
});
it('generates cache key with varyBy parameters', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('products:filter'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([
'category' => 'electronics',
'price_min' => 100,
'price_max' => 500,
'sort' => 'price_asc',
]));
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(10),
varyBy: ['category', 'price_min', 'price_max'],
staleWhileRevalidate: false
);
$html = '<div>Product list</div>';
$this->cache
->shouldReceive('set')
->once()
->with(Mockery::on(function (CacheItem $item) {
$keyString = $item->key->toString();
// Key should include component ID and varyBy parameters
return str_contains($keyString, 'products:filter')
&& str_contains($keyString, 'electronics')
&& str_contains($keyString, '100')
&& str_contains($keyString, '500');
}))
->andReturn(true);
$this->cacheManager->set($component, $html, $config);
});
it('retrieves cached component by exact key match', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('stats:user-123'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['views' => 100]));
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5)
);
$cachedHtml = '<div class="stats">Views: 100</div>';
$this->cache
->shouldReceive('get')
->once()
->andReturn(CacheItem::forGetting(
key: CacheKey::fromString('livecomponent:stats:user-123'),
value: $cachedHtml
));
$result = $this->cacheManager->get($component, $config);
expect($result)->toBe($cachedHtml);
});
it('returns null when cache miss', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('stats:user-999'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
$config = new CacheConfig(enabled: true, ttl: Duration::fromMinutes(5));
$this->cache
->shouldReceive('get')
->once()
->andReturn(null);
$result = $this->cacheManager->get($component, $config);
expect($result)->toBeNull();
});
it('invalidates component cache', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('cart:user-123'));
$this->cache
->shouldReceive('forget')
->once()
->with(Mockery::on(function ($key) {
return str_contains($key->toString(), 'cart:user-123');
}))
->andReturn(true);
$result = $this->cacheManager->invalidate($component);
expect($result)->toBeTrue();
});
it('supports stale-while-revalidate pattern', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('news:feed'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5),
varyBy: [],
staleWhileRevalidate: true,
staleWhileRevalidateTtl: Duration::fromMinutes(60)
);
$html = '<div>News feed</div>';
$this->cache
->shouldReceive('set')
->once()
->with(Mockery::on(function (CacheItem $item) {
// SWR should use extended TTL
return $item->ttl->toSeconds() === 3600; // 60 minutes
}))
->andReturn(true);
$this->cacheManager->set($component, $html, $config);
});
it('varyBy with different values creates different cache keys', function () {
$component1 = Mockery::mock(LiveComponent::class);
$component1->shouldReceive('getId')->andReturn(ComponentId::fromString('search:results'));
$component1->shouldReceive('getData')->andReturn(ComponentData::fromArray([
'query' => 'laptop',
'page' => 1,
]));
$component2 = Mockery::mock(LiveComponent::class);
$component2->shouldReceive('getId')->andReturn(ComponentId::fromString('search:results'));
$component2->shouldReceive('getData')->andReturn(ComponentData::fromArray([
'query' => 'laptop',
'page' => 2,
]));
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(10),
varyBy: ['query', 'page']
);
$capturedKeys = [];
$this->cache
->shouldReceive('set')
->twice()
->with(Mockery::on(function (CacheItem $item) use (&$capturedKeys) {
$capturedKeys[] = $item->key->toString();
return true;
}))
->andReturn(true);
$this->cacheManager->set($component1, '<div>Page 1</div>', $config);
$this->cacheManager->set($component2, '<div>Page 2</div>', $config);
// Keys should be different because page number differs
expect($capturedKeys[0])->not->toBe($capturedKeys[1]);
expect($capturedKeys[0])->toContain('laptop');
expect($capturedKeys[1])->toContain('laptop');
});
it('ignores cache when config disabled', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('realtime:feed'));
$config = new CacheConfig(enabled: false, ttl: Duration::fromMinutes(5));
$this->cache->shouldNotReceive('set');
$this->cache->shouldNotReceive('get');
$result = $this->cacheManager->set($component, '<div>Feed</div>', $config);
expect($result)->toBeFalse();
$cached = $this->cacheManager->get($component, $config);
expect($cached)->toBeNull();
});
it('handles empty varyBy array correctly', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('static:banner'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['message' => 'Welcome']));
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromHours(1),
varyBy: [] // No variation
);
$this->cache
->shouldReceive('set')
->once()
->with(Mockery::on(function (CacheItem $item) {
// Should only include component ID, no variation parameters
return str_contains($item->key->toString(), 'static:banner');
}))
->andReturn(true);
$this->cacheManager->set($component, '<div>Banner</div>', $config);
});
it('respects custom TTL durations', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('test:component'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
$testCases = [
Duration::fromSeconds(30) => 30,
Duration::fromMinutes(15) => 900,
Duration::fromHours(2) => 7200,
Duration::fromDays(1) => 86400,
];
foreach ($testCases as $duration => $expectedSeconds) {
$config = new CacheConfig(enabled: true, ttl: $duration);
$this->cache
->shouldReceive('set')
->once()
->with(Mockery::on(function (CacheItem $item) use ($expectedSeconds) {
return $item->ttl->toSeconds() === $expectedSeconds;
}))
->andReturn(true);
$this->cacheManager->set($component, '<div>Test</div>', $config);
}
});
});

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\LiveComponents;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
/**
* Factory for creating test components
*
* Simplifies component creation in tests with builder pattern.
*
* Usage:
* ```php
* // Simple component with initial state
* $component = ComponentFactory::make()
* ->withId('counter:test')
* ->withState(['count' => 0])
* ->create();
*
* // Component with custom actions
* $component = ComponentFactory::make()
* ->withId('posts:manager')
* ->withState(['posts' => []])
* ->withAction('addPost', function(string $title) {
* $this->state['posts'][] = $title;
* return ComponentData::fromArray($this->state);
* })
* ->create();
* ```
*/
final class ComponentFactory
{
private string $id = 'test:component';
private array $initialState = [];
private array $actions = [];
private ?string $template = null;
private function __construct()
{
}
/**
* Create new factory instance
*/
public static function make(): self
{
return new self();
}
/**
* Set component ID
*/
public function withId(string $id): self
{
$this->id = $id;
return $this;
}
/**
* Set initial state
*
* @param array<string, mixed> $state Initial state data
*/
public function withState(array $state): self
{
$this->initialState = $state;
return $this;
}
/**
* Add custom action
*
* @param string $name Action method name
* @param callable $handler Action handler
*/
public function withAction(string $name, callable $handler): self
{
$this->actions[$name] = $handler;
return $this;
}
/**
* Set template name
*/
public function withTemplate(string $template): self
{
$this->template = $template;
return $this;
}
/**
* Create component instance
*/
public function create(): LiveComponentContract
{
$componentId = ComponentId::fromString($this->id);
$initialState = $this->initialState;
$actions = $this->actions;
$template = $this->template ?? 'test-component';
return new class ($componentId, $initialState, $actions, $template) implements LiveComponentContract {
private array $state;
public function __construct(
private ComponentId $id,
array $initialState,
private array $actions,
private string $template
) {
$this->state = $initialState;
}
public function getId(): ComponentId
{
return $this->id;
}
public function getData(): ComponentData
{
return ComponentData::fromArray($this->state);
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData($this->template, $this->state);
}
public function __call(string $method, array $arguments): mixed
{
if (isset($this->actions[$method])) {
// Bind action to this component instance
$action = $this->actions[$method]->bindTo($this, self::class);
return $action(...$arguments);
}
throw new \BadMethodCallException("Method {$method} not found on test component");
}
};
}
/**
* Create simple counter component
*
* Pre-configured counter with increment/decrement/reset actions.
*/
public static function counter(int $initialCount = 0): LiveComponentContract
{
return self::make()
->withId('counter:test')
->withState(['count' => $initialCount])
->withAction('increment', function () {
$this->state['count']++;
return ComponentData::fromArray($this->state);
})
->withAction('decrement', function () {
$this->state['count']--;
return ComponentData::fromArray($this->state);
})
->withAction('reset', function () {
$this->state['count'] = 0;
return ComponentData::fromArray($this->state);
})
->create();
}
/**
* Create simple list component
*
* Pre-configured list with add/remove actions.
*
* @param array<string> $initialItems Initial list items
*/
public static function list(array $initialItems = []): LiveComponentContract
{
return self::make()
->withId('list:test')
->withState(['items' => $initialItems])
->withAction('addItem', function (string $item) {
$this->state['items'][] = $item;
return ComponentData::fromArray($this->state);
})
->withAction('removeItem', function (int $index) {
array_splice($this->state['items'], $index, 1);
return ComponentData::fromArray($this->state);
})
->withAction('clear', function () {
$this->state['items'] = [];
return ComponentData::fromArray($this->state);
})
->create();
}
}

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\LiveComponents;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\Session\Session;
use App\Framework\Http\Session\SessionId;
use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Exceptions\StateValidationException;
use App\Framework\LiveComponents\Exceptions\UnauthorizedActionException;
use App\Framework\LiveComponents\LiveComponentHandler;
use App\Framework\LiveComponents\Security\SessionBasedAuthorizationChecker;
use App\Framework\LiveComponents\Validation\DefaultStateValidator;
use App\Framework\LiveComponents\Validation\SchemaCache;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
use App\Framework\Random\SecureRandomGenerator;
use App\Framework\Security\CsrfTokenGenerator;
/**
* Test harness for LiveComponent testing
*
* Provides comprehensive helper methods for testing LiveComponents:
* - Component setup and lifecycle
* - Action execution with CSRF, Authorization, Validation
* - State assertions
* - Event assertions
* - User/Permission mocking
*
* Usage in Pest tests:
* ```php
* use Tests\Framework\LiveComponents\ComponentTestCase;
*
* uses(ComponentTestCase::class);
*
* it('executes action', function () {
* $component = $this->createComponent(CounterComponent::class);
* $result = $this->callAction($component, 'increment');
* $this->assertStateEquals($result, ['count' => 1]);
* });
* ```
*/
trait ComponentTestCase
{
protected Session $session;
protected LiveComponentHandler $handler;
protected ComponentEventDispatcher $eventDispatcher;
protected string $csrfToken;
/**
* Setup test environment
*
* Creates session, handler, and generates CSRF token.
* Call this in beforeEach() hook.
*/
protected function setUpComponentTest(): void
{
// Create session
$sessionId = SessionId::fromString(bin2hex(random_bytes(16)));
$clock = new SystemClock();
$randomGenerator = new SecureRandomGenerator();
$csrfGenerator = new CsrfTokenGenerator($randomGenerator);
$this->session = Session::fromArray($sessionId, $clock, $csrfGenerator, []);
// Create handler dependencies
$this->eventDispatcher = new ComponentEventDispatcher();
$authChecker = new SessionBasedAuthorizationChecker($this->session);
$stateValidator = new DefaultStateValidator();
$schemaCache = new SchemaCache();
$this->handler = new LiveComponentHandler(
$this->eventDispatcher,
$this->session,
$authChecker,
$stateValidator,
$schemaCache
);
}
/**
* Authenticate user with permissions
*
* @param array<string> $permissions User permissions
* @param int $userId User ID
*/
protected function actingAs(array $permissions = [], int $userId = 1): self
{
$this->session->set('user', [
'id' => $userId,
'permissions' => $permissions,
]);
return $this;
}
/**
* Call component action
*
* Automatically generates CSRF token for the component.
*
* @param LiveComponentContract $component Component instance
* @param string $method Action method name
* @param array<string, mixed> $params Action parameters
* @return ComponentUpdate Action result
*/
protected function callAction(
LiveComponentContract $component,
string $method,
array $params = []
): ComponentUpdate {
// Generate CSRF token for component
$formId = 'livecomponent:' . $component->getId()->toString();
$csrfToken = $this->session->csrf->generateToken($formId);
$actionParams = ActionParameters::fromArray($params, $csrfToken);
return $this->handler->handle($component, $method, $actionParams);
}
/**
* Assert action executes successfully
*
* @param LiveComponentContract $component Component instance
* @param string $method Action method name
* @param array<string, mixed> $params Action parameters
*/
protected function assertActionExecutes(
LiveComponentContract $component,
string $method,
array $params = []
): ComponentUpdate {
$result = $this->callAction($component, $method, $params);
expect($result)->toBeInstanceOf(ComponentUpdate::class);
return $result;
}
/**
* Assert action throws exception
*
* @param LiveComponentContract $component Component instance
* @param string $method Action method name
* @param string $exceptionClass Expected exception class
* @param array<string, mixed> $params Action parameters
*/
protected function assertActionThrows(
LiveComponentContract $component,
string $method,
string $exceptionClass,
array $params = []
): void {
$thrown = false;
$caughtException = null;
try {
$this->callAction($component, $method, $params);
} catch (\Throwable $e) {
$thrown = true;
$caughtException = $e;
}
if (! $thrown) {
throw new \AssertionError("Expected exception {$exceptionClass} to be thrown, but no exception was thrown");
}
if (! ($caughtException instanceof $exceptionClass)) {
throw new \AssertionError(
"Expected exception of type {$exceptionClass}, got " . get_class($caughtException)
);
}
}
/**
* Assert action requires authentication
*
* @param LiveComponentContract $component Component with protected action
* @param string $method Protected action method
*/
protected function assertActionRequiresAuth(
LiveComponentContract $component,
string $method
): void {
$this->assertActionThrows(
$component,
$method,
UnauthorizedActionException::class
);
}
/**
* Assert action requires permission
*
* @param LiveComponentContract $component Component with protected action
* @param string $method Protected action method
* @param array<string> $withoutPermissions User permissions (should fail)
*/
protected function assertActionRequiresPermission(
LiveComponentContract $component,
string $method,
array $withoutPermissions = []
): void {
$this->actingAs($withoutPermissions);
$this->assertActionThrows(
$component,
$method,
UnauthorizedActionException::class
);
}
/**
* Assert state equals expected values
*
* @param ComponentUpdate $result Action result
* @param array<string, mixed> $expected Expected state data
*/
protected function assertStateEquals(ComponentUpdate $result, array $expected): void
{
$actualState = $result->state->data;
foreach ($expected as $key => $value) {
expect($actualState)->toHaveKey($key);
expect($actualState[$key])->toBe($value);
}
}
/**
* Assert state has key
*
* @param ComponentUpdate $result Action result
* @param string $key State key
*/
protected function assertStateHas(ComponentUpdate $result, string $key): void
{
expect($result->state->data)->toHaveKey($key);
}
/**
* Assert state validates against schema
*
* @param ComponentUpdate $result Action result
*/
protected function assertStateValidates(ComponentUpdate $result): void
{
// If we got here without StateValidationException, validation passed
expect($result)->toBeInstanceOf(ComponentUpdate::class);
}
/**
* Assert event was dispatched
*
* @param ComponentUpdate $result Action result
* @param string $eventName Event name
*/
protected function assertEventDispatched(ComponentUpdate $result, string $eventName): void
{
$events = $result->events;
$found = false;
foreach ($events as $event) {
if ($event->name === $eventName) {
$found = true;
break;
}
}
expect($found)->toBeTrue("Event '{$eventName}' was not dispatched");
}
/**
* Assert no events were dispatched
*
* @param ComponentUpdate $result Action result
*/
protected function assertNoEventsDispatched(ComponentUpdate $result): void
{
expect($result->events)->toBeEmpty();
}
/**
* Assert event count
*
* @param ComponentUpdate $result Action result
* @param int $count Expected event count
*/
protected function assertEventCount(ComponentUpdate $result, int $count): void
{
expect($result->events)->toHaveCount($count);
}
/**
* Get state value from result
*
* @param ComponentUpdate $result Action result
* @param string $key State key
* @return mixed State value
*/
protected function getStateValue(ComponentUpdate $result, string $key): mixed
{
return $result->state->data[$key] ?? null;
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\LiveComponents;
use App\Framework\Filesystem\InMemoryStorage;
use App\Framework\LiveComponents\Services\IntegrityValidator;
use App\Framework\LiveComponents\ValueObjects\ChunkHash;
beforeEach(function () {
$this->validator = new IntegrityValidator();
$this->fileStorage = new InMemoryStorage();
});
test('verifies chunk data with correct hash', function () {
$chunkData = str_repeat('A', 512);
$correctHash = ChunkHash::fromData($chunkData);
$result = $this->validator->verifyChunk($chunkData, $correctHash);
expect($result)->toBeTrue();
});
test('rejects chunk data with incorrect hash', function () {
$chunkData = str_repeat('A', 512);
$wrongHash = ChunkHash::fromData('different data');
$result = $this->validator->verifyChunk($chunkData, $wrongHash);
expect($result)->toBeFalse();
});
test('calculates correct chunk hash', function () {
$chunkData = str_repeat('B', 1024);
$calculatedHash = $this->validator->calculateChunkHash($chunkData);
$expectedHash = ChunkHash::fromData($chunkData);
expect($calculatedHash->toString())->toBe($expectedHash->toString());
});
test('verifies file with correct hash', function () {
$filePath = '/tmp/test-file.txt';
$content = 'Test file content for hash verification';
$this->fileStorage->put($filePath, $content);
$correctHash = ChunkHash::fromFile($filePath);
$result = $this->validator->verifyFile($filePath, $correctHash);
expect($result)->toBeTrue();
});
test('rejects file with incorrect hash', function () {
$filePath = '/tmp/test-file.txt';
$content = 'Test file content';
$this->fileStorage->put($filePath, $content);
$wrongHash = ChunkHash::fromData('different content');
$result = $this->validator->verifyFile($filePath, $wrongHash);
expect($result)->toBeFalse();
});
test('returns false for non-existent file', function () {
$nonExistentFile = '/tmp/does-not-exist.txt';
$someHash = ChunkHash::fromData('some data');
$result = $this->validator->verifyFile($nonExistentFile, $someHash);
expect($result)->toBeFalse();
});
test('calculates correct file hash', function () {
$filePath = '/tmp/test-file.txt';
$content = 'File content for hash calculation test';
$this->fileStorage->put($filePath, $content);
$calculatedHash = $this->validator->calculateFileHash($filePath);
$expectedHash = ChunkHash::fromFile($filePath);
expect($calculatedHash->toString())->toBe($expectedHash->toString());
});
test('verifies identical chunk hashes match', function () {
$data = 'consistent test data';
$hash1 = $this->validator->calculateChunkHash($data);
$hash2 = $this->validator->calculateChunkHash($data);
expect($this->validator->verifyChunk($data, $hash1))->toBeTrue();
expect($this->validator->verifyChunk($data, $hash2))->toBeTrue();
expect($hash1->toString())->toBe($hash2->toString());
});
test('detects modified chunk data', function () {
$originalData = 'original chunk data';
$originalHash = ChunkHash::fromData($originalData);
$modifiedData = 'modified chunk data';
$result = $this->validator->verifyChunk($modifiedData, $originalHash);
expect($result)->toBeFalse();
});
test('handles empty chunk data', function () {
$emptyData = '';
$hash = $this->validator->calculateChunkHash($emptyData);
expect($this->validator->verifyChunk($emptyData, $hash))->toBeTrue();
});
test('handles large chunk data', function () {
// 5MB chunk
$largeData = str_repeat('X', 5 * 1024 * 1024);
$hash = $this->validator->calculateChunkHash($largeData);
expect($this->validator->verifyChunk($largeData, $hash))->toBeTrue();
});
test('verifies file hash remains consistent', function () {
$filePath = '/tmp/consistent-file.txt';
$content = 'Consistent file content';
$this->fileStorage->put($filePath, $content);
$hash1 = $this->validator->calculateFileHash($filePath);
$hash2 = $this->validator->calculateFileHash($filePath);
expect($hash1->toString())->toBe($hash2->toString());
expect($this->validator->verifyFile($filePath, $hash1))->toBeTrue();
expect($this->validator->verifyFile($filePath, $hash2))->toBeTrue();
});

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Observability\ComponentMetricsCollector;
describe('ComponentMetricsCollector - Basic Functionality', function () {
it('can be instantiated', function () {
$collector = new ComponentMetricsCollector();
expect($collector)->toBeInstanceOf(ComponentMetricsCollector::class);
});
it('starts with empty metrics', function () {
$collector = new ComponentMetricsCollector();
$metrics = $collector->getMetrics();
expect($metrics)->toBeArray();
expect(count($metrics))->toBe(0);
});
it('records render metrics', function () {
$collector = new ComponentMetricsCollector();
$collector->recordRender('comp-123', 45.5, false);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records action execution', function () {
$collector = new ComponentMetricsCollector();
$collector->recordAction('comp-123', 'handleClick', 25.5, true);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records cache hits', function () {
$collector = new ComponentMetricsCollector();
$collector->recordCacheHit('comp-123', true);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records cache misses', function () {
$collector = new ComponentMetricsCollector();
$collector->recordCacheHit('comp-123', false);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records event dispatching', function () {
$collector = new ComponentMetricsCollector();
$collector->recordEventDispatched('comp-123', 'user:updated');
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records event receiving', function () {
$collector = new ComponentMetricsCollector();
$collector->recordEventReceived('comp-123', 'data:loaded');
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records hydration time', function () {
$collector = new ComponentMetricsCollector();
$collector->recordHydration('comp-123', 15.5);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records batch operations', function () {
$collector = new ComponentMetricsCollector();
$collector->recordBatch(10, 125.5, 8, 2);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records fragment updates', function () {
$collector = new ComponentMetricsCollector();
$collector->recordFragmentUpdate('comp-123', 3, 45.5);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records upload chunks', function () {
$collector = new ComponentMetricsCollector();
$collector->recordUploadChunk('session-abc', 0, 125.5, true);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('records upload completion', function () {
$collector = new ComponentMetricsCollector();
$collector->recordUploadComplete('session-abc', 1500.5, 5);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
});
it('generates summary statistics', function () {
$collector = new ComponentMetricsCollector();
$collector->recordRender('comp-1', 45.5, false);
$collector->recordAction('comp-1', 'handleClick', 25.5, true);
$collector->recordCacheHit('comp-1', true);
$summary = $collector->getSummary();
expect($summary)->toBeArray();
expect(isset($summary['total_renders']))->toBeTrue();
expect(isset($summary['total_actions']))->toBeTrue();
expect(isset($summary['cache_hits']))->toBeTrue();
expect(isset($summary['cache_misses']))->toBeTrue();
expect(isset($summary['cache_hit_rate']))->toBeTrue();
});
it('exports Prometheus format', function () {
$collector = new ComponentMetricsCollector();
$collector->recordRender('comp-1', 45.5, false);
$collector->recordAction('comp-1', 'handleClick', 25.5, true);
$prometheus = $collector->exportPrometheus();
expect($prometheus)->toBeString();
expect(str_contains($prometheus, '# HELP'))->toBeTrue();
expect(str_contains($prometheus, 'livecomponent_'))->toBeTrue();
});
it('can reset metrics', function () {
$collector = new ComponentMetricsCollector();
$collector->recordRender('comp-1', 45.5, false);
expect(count($collector->getMetrics()))->toBeGreaterThan(0);
$collector->reset();
expect(count($collector->getMetrics()))->toBe(0);
});
it('calculates cache hit rate correctly', function () {
$collector = new ComponentMetricsCollector();
// 2 hits, 1 miss = 66.7% hit rate
$collector->recordCacheHit('comp-1', true);
$collector->recordCacheHit('comp-2', true);
$collector->recordCacheHit('comp-3', false);
$summary = $collector->getSummary();
expect($summary['cache_hits'])->toBe(2);
expect($summary['cache_misses'])->toBe(1);
expect($summary['cache_hit_rate'])->toBeGreaterThan(60.0);
expect($summary['cache_hit_rate'])->toBeLessThan(70.0);
});
it('handles action errors separately', function () {
$collector = new ComponentMetricsCollector();
$collector->recordAction('comp-1', 'handleClick', 25.5, false);
$summary = $collector->getSummary();
expect($summary['action_errors'])->toBe(1);
});
it('tracks multiple components', function () {
$collector = new ComponentMetricsCollector();
$collector->recordRender('comp-1', 45.5, false);
$collector->recordRender('comp-2', 30.2, true);
$collector->recordAction('comp-1', 'handleClick', 25.5, true);
$collector->recordAction('comp-2', 'handleSubmit', 35.8, true);
$summary = $collector->getSummary();
expect($summary['total_renders'])->toBe(2);
expect($summary['total_actions'])->toBe(2);
});
it('handles zero operations gracefully', function () {
$collector = new ComponentMetricsCollector();
$summary = $collector->getSummary();
expect($summary['total_renders'])->toBe(0);
expect($summary['total_actions'])->toBe(0);
expect($summary['cache_hit_rate'])->toBe(0.0);
});
});

View File

@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Observability\ComponentMetricsCollector;
use App\Framework\Performance\PerformanceCollector;
describe('ComponentMetricsCollector', function () {
it('records component render with metrics', function () {
$collector = new ComponentMetricsCollector();
$collector->recordRender('test-component-123', 45.5, false);
$metrics = $collector->getMetrics();
expect(count($metrics))->toBeGreaterThan(0);
expect(isset($metrics['livecomponent_renders_total{component_id=test-component-123,cached=false}']))->toBeTrue();
});
it('records cached vs non-cached renders separately', function () {
$collector = new ComponentMetricsCollector();
$collector->recordRender('comp-1', 45.5, false); // non-cached
$collector->recordRender('comp-2', 10.2, true); // cached
$metrics = $collector->getMetrics();
// Should have separate metrics for cached and non-cached
$renderMetrics = array_filter(
$metrics,
fn($m) => str_contains($m->name, 'livecomponent_renders_total')
);
expect(count($renderMetrics))->toBeGreaterThanOrEqual(1);
});
it('records action execution with duration', function () {
$collector = new ComponentMetricsCollector();
$collector->recordAction('comp-123', 'handleClick', 25.5, true);
$metrics = $collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_actions_total');
expect($metrics['livecomponent_actions_total']->value)->toBe(1.0);
});
it('tracks action errors separately', function () {
$collector = new ComponentMetricsCollector();
$collector->recordAction('comp-123', 'handleClick', 25.5, false);
$metrics = $collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_action_errors_total');
expect($metrics['livecomponent_action_errors_total']->value)->toBe(1.0);
});
it('records cache hits and misses', function () {
$collector = new ComponentMetricsCollector();
$collector->recordCacheHit('comp-1', true);
$collector->recordCacheHit('comp-2', false);
$collector->recordCacheHit('comp-3', true);
$metrics = $collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_cache_hits_total');
expect($metrics)->toHaveKey('livecomponent_cache_misses_total');
expect($metrics['livecomponent_cache_hits_total']->value)->toBe(2.0);
expect($metrics['livecomponent_cache_misses_total']->value)->toBe(1.0);
});
it('records event dispatching', function () {
$collector = new ComponentMetricsCollector();
$collector->recordEventDispatched('comp-1', 'user:updated');
$collector->recordEventDispatched('comp-2', 'data:loaded');
$metrics = $collector->getMetrics();
$eventMetrics = array_filter(
$metrics,
fn($m) => str_contains($m->name, 'livecomponent_events_dispatched_total')
);
expect(count($eventMetrics))->toBeGreaterThanOrEqual(1);
});
it('records event receiving', function () {
$collector = new ComponentMetricsCollector();
$collector->recordEventReceived('comp-1', 'user:updated');
$collector->recordEventReceived('comp-2', 'data:loaded');
$metrics = $collector->getMetrics();
$eventMetrics = array_filter(
$metrics,
fn($m) => str_contains($m->name, 'livecomponent_events_received_total')
);
expect(count($eventMetrics))->toBeGreaterThanOrEqual(1);
});
it('records hydration time', function () {
$collector = new ComponentMetricsCollector();
$collector->recordHydration('comp-123', 15.5);
$metrics = $collector->getMetrics();
$hydrationMetrics = array_filter(
$metrics,
fn($m) => str_contains($m->name, 'livecomponent_hydration_duration_ms')
);
expect(count($hydrationMetrics))->toBeGreaterThanOrEqual(1);
});
it('records batch operations', function () {
$collector = new ComponentMetricsCollector();
$collector->recordBatch(10, 125.5, 8, 2);
$metrics = $collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_batch_operations_total');
expect($metrics)->toHaveKey('livecomponent_batch_success_total');
expect($metrics)->toHaveKey('livecomponent_batch_failure_total');
expect($metrics['livecomponent_batch_success_total']->value)->toBe(8.0);
expect($metrics['livecomponent_batch_failure_total']->value)->toBe(2.0);
});
it('records fragment updates', function () {
$collector = new ComponentMetricsCollector();
$collector->recordFragmentUpdate('comp-123', 3, 45.5);
$metrics = $collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_fragment_updates_total');
});
it('records upload chunks', function () {
$collector = new ComponentMetricsCollector();
$collector->recordUploadChunk('session-abc', 0, 125.5, true);
$collector->recordUploadChunk('session-abc', 1, 130.2, true);
$collector->recordUploadChunk('session-abc', 2, 128.8, false);
$metrics = $collector->getMetrics();
$uploadMetrics = array_filter(
$metrics,
fn($m) => str_contains($m->name, 'livecomponent_upload_chunks_total')
);
expect(count($uploadMetrics))->toBeGreaterThanOrEqual(1);
});
it('records upload completion', function () {
$collector = new ComponentMetricsCollector();
$collector->recordUploadComplete('session-abc', 1500.5, 5);
$metrics = $collector->getMetrics();
expect($metrics)->toHaveKey('livecomponent_uploads_completed_total');
});
it('calculates summary statistics', function () {
$collector = new ComponentMetricsCollector();
// Record various operations
$collector->recordRender('comp-1', 45.5, false);
$collector->recordRender('comp-2', 30.2, true);
$collector->recordAction('comp-1', 'handleClick', 25.5, true);
$collector->recordAction('comp-2', 'handleSubmit', 35.8, false);
$collector->recordCacheHit('comp-1', true);
$collector->recordCacheHit('comp-2', false);
$collector->recordCacheHit('comp-3', true);
$summary = $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('cache_hit_rate');
expect($summary)->toHaveKey('action_errors');
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['action_errors'])->toBe(1);
expect($summary['cache_hit_rate'])->toBeGreaterThan(60.0);
});
it('exports metrics in Prometheus format', function () {
$collector = new ComponentMetricsCollector();
$collector->recordRender('comp-1', 45.5, false);
$collector->recordAction('comp-1', 'handleClick', 25.5, true);
$prometheus = $collector->exportPrometheus();
expect($prometheus)->toBeString();
expect($prometheus)->toContain('# HELP LiveComponents metrics');
expect($prometheus)->toContain('livecomponent_');
});
it('resets all metrics', function () {
$collector = new ComponentMetricsCollector();
$collector->recordRender('comp-1', 45.5, false);
$collector->recordAction('comp-1', 'handleClick', 25.5, true);
expect($collector->getMetrics())->not->toBeEmpty();
$collector->reset();
expect($collector->getMetrics())->toBeEmpty();
});
it('integrates with PerformanceCollector', function () {
$performanceCollector = $this->createMock(PerformanceCollector::class);
$performanceCollector->expects($this->once())
->method('recordMetric')
->with(
$this->stringContains('livecomponent.render'),
$this->anything(),
$this->equalTo(45.5),
$this->anything()
);
$collector = new ComponentMetricsCollector($performanceCollector);
$collector->recordRender('comp-1', 45.5, false);
});
it('tracks multiple components independently', function () {
$collector = new ComponentMetricsCollector();
// Component 1: 3 renders, 2 actions
$collector->recordRender('comp-1', 45.5, false);
$collector->recordRender('comp-1', 40.2, false);
$collector->recordRender('comp-1', 35.8, false);
$collector->recordAction('comp-1', 'handleClick', 25.5, true);
$collector->recordAction('comp-1', 'handleSubmit', 30.2, true);
// Component 2: 2 renders, 1 action
$collector->recordRender('comp-2', 20.5, true);
$collector->recordRender('comp-2', 18.2, true);
$collector->recordAction('comp-2', 'handleClick', 15.5, true);
$metrics = $collector->getMetrics();
// Should have metrics for both components
$renderMetrics = array_filter(
$metrics,
fn($m) => str_contains($m->name, 'livecomponent_renders_total')
);
expect(count($renderMetrics))->toBeGreaterThanOrEqual(1);
});
it('handles edge case with zero operations', function () {
$collector = new ComponentMetricsCollector();
$summary = $collector->getSummary();
expect($summary['total_renders'])->toBe(0);
expect($summary['total_actions'])->toBe(0);
expect($summary['cache_hit_rate'])->toBe(0.0);
});
it('handles edge case with all cache misses', function () {
$collector = new ComponentMetricsCollector();
$collector->recordCacheHit('comp-1', false);
$collector->recordCacheHit('comp-2', false);
$collector->recordCacheHit('comp-3', false);
$summary = $collector->getSummary();
expect($summary['cache_hits'])->toBe(0);
expect($summary['cache_misses'])->toBe(3);
expect($summary['cache_hit_rate'])->toBe(0.0);
});
it('handles edge case with all cache hits', function () {
$collector = new ComponentMetricsCollector();
$collector->recordCacheHit('comp-1', true);
$collector->recordCacheHit('comp-2', true);
$collector->recordCacheHit('comp-3', true);
$summary = $collector->getSummary();
expect($summary['cache_hits'])->toBe(3);
expect($summary['cache_misses'])->toBe(0);
expect($summary['cache_hit_rate'])->toBe(100.0);
});
});

View File

@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\LiveComponents;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Filesystem\InMemoryStorage;
use App\Framework\LiveComponents\Services\QuarantineService;
use App\Framework\LiveComponents\ValueObjects\QuarantineStatus;
use App\Framework\LiveComponents\ValueObjects\ScanResult;
use App\Framework\LiveComponents\ValueObjects\ScanStatus;
use DateTimeImmutable;
beforeEach(function () {
$this->fileStorage = new InMemoryStorage();
$this->service = new QuarantineService(
fileStorage: $this->fileStorage,
quarantinePath: '/tmp/quarantine',
defaultRetentionPeriod: Duration::fromHours(24)
);
});
test('quarantines file successfully', function () {
// Create source file
$sourcePath = '/tmp/source/test-file.txt';
$this->fileStorage->put($sourcePath, 'test content');
// Quarantine file
$quarantineId = 'test-quarantine-123';
$quarantinePath = $this->service->quarantine($sourcePath, $quarantineId);
// Verify file moved to quarantine
expect($this->fileStorage->exists($quarantinePath))->toBeTrue();
expect($this->fileStorage->exists($sourcePath))->toBeFalse();
expect($quarantinePath)->toBe('/tmp/quarantine/' . $quarantineId);
});
test('throws exception when quarantining non-existent file', function () {
$nonExistentPath = '/tmp/does-not-exist.txt';
$quarantineId = 'test-quarantine-123';
expect(fn() => $this->service->quarantine($nonExistentPath, $quarantineId))
->toThrow(\InvalidArgumentException::class, 'Source file not found');
});
test('releases quarantined file to target location', function () {
// Setup: quarantine a file
$sourcePath = '/tmp/source/file.txt';
$this->fileStorage->put($sourcePath, 'quarantined content');
$quarantineId = 'release-test-456';
$this->service->quarantine($sourcePath, $quarantineId);
// Release file
$targetPath = '/tmp/target/released-file.txt';
$this->service->release($quarantineId, $targetPath);
// Verify file moved to target
expect($this->fileStorage->exists($targetPath))->toBeTrue();
expect($this->service->exists($quarantineId))->toBeFalse();
expect($this->fileStorage->get($targetPath))->toBe('quarantined content');
});
test('throws exception when releasing non-existent quarantined file', function () {
$nonExistentQuarantineId = 'does-not-exist';
$targetPath = '/tmp/target/file.txt';
expect(fn() => $this->service->release($nonExistentQuarantineId, $targetPath))
->toThrow(\InvalidArgumentException::class, 'Quarantined file not found');
});
test('deletes quarantined file', function () {
// Setup: quarantine a file
$sourcePath = '/tmp/source/to-delete.txt';
$this->fileStorage->put($sourcePath, 'delete me');
$quarantineId = 'delete-test-789';
$this->service->quarantine($sourcePath, $quarantineId);
// Delete quarantined file
$this->service->delete($quarantineId);
// Verify file deleted
expect($this->service->exists($quarantineId))->toBeFalse();
});
test('delete handles non-existent quarantine gracefully', function () {
$nonExistentId = 'never-existed';
// Should not throw exception
$this->service->delete($nonExistentId);
expect($this->service->exists($nonExistentId))->toBeFalse();
});
test('checks quarantine existence correctly', function () {
$sourcePath = '/tmp/source/exist-check.txt';
$this->fileStorage->put($sourcePath, 'existence test');
$quarantineId = 'exist-test-101';
// Before quarantine
expect($this->service->exists($quarantineId))->toBeFalse();
// After quarantine
$this->service->quarantine($sourcePath, $quarantineId);
expect($this->service->exists($quarantineId))->toBeTrue();
// After deletion
$this->service->delete($quarantineId);
expect($this->service->exists($quarantineId))->toBeFalse();
});
test('gets correct quarantine path', function () {
$quarantineId = 'path-test-202';
$expectedPath = '/tmp/quarantine/' . $quarantineId;
$actualPath = $this->service->getQuarantinePath($quarantineId);
expect($actualPath)->toBe($expectedPath);
});
test('scans file with clean result', function () {
// Setup: quarantine a file
$sourcePath = '/tmp/source/clean-file.txt';
$this->fileStorage->put($sourcePath, 'clean content');
$quarantineId = 'scan-clean-303';
$this->service->quarantine($sourcePath, $quarantineId);
// Mock scanner that returns clean
$scannerHook = function (string $filePath): ScanResult {
return ScanResult::clean('No threats detected');
};
// Scan file
$result = $this->service->scan($quarantineId, $scannerHook);
expect($result->isClean())->toBeTrue();
expect($result->status)->toBe(ScanStatus::CLEAN);
});
test('scans file with infected result', function () {
// Setup: quarantine a file
$sourcePath = '/tmp/source/infected-file.txt';
$this->fileStorage->put($sourcePath, 'infected content');
$quarantineId = 'scan-infected-404';
$this->service->quarantine($sourcePath, $quarantineId);
// Mock scanner that detects threat
$scannerHook = function (string $filePath): ScanResult {
return ScanResult::infected(
threatName: 'Trojan.Generic',
confidenceScore: 0.95,
details: 'Malicious payload detected'
);
};
// Scan file
$result = $this->service->scan($quarantineId, $scannerHook);
expect($result->isInfected())->toBeTrue();
expect($result->threatName)->toBe('Trojan.Generic');
expect($result->confidenceScore)->toBe(0.95);
});
test('scans file with suspicious result', function () {
// Setup: quarantine a file
$sourcePath = '/tmp/source/suspicious-file.txt';
$this->fileStorage->put($sourcePath, 'suspicious content');
$quarantineId = 'scan-suspicious-505';
$this->service->quarantine($sourcePath, $quarantineId);
// Mock scanner that finds suspicious patterns
$scannerHook = function (string $filePath): ScanResult {
return ScanResult::suspicious(
details: 'Potentially unwanted program',
confidenceScore: 0.65
);
};
// Scan file
$result = $this->service->scan($quarantineId, $scannerHook);
expect($result->isSuspicious())->toBeTrue();
expect($result->shouldQuarantine())->toBeTrue();
});
test('throws exception when scanning non-existent quarantined file', function () {
$nonExistentId = 'never-scanned';
$scannerHook = fn($path) => ScanResult::clean();
expect(fn() => $this->service->scan($nonExistentId, $scannerHook))
->toThrow(\InvalidArgumentException::class, 'Quarantined file not found');
});
test('throws exception when scanner hook returns invalid type', function () {
// Setup: quarantine a file
$sourcePath = '/tmp/source/invalid-scanner.txt';
$this->fileStorage->put($sourcePath, 'content');
$quarantineId = 'invalid-scanner-606';
$this->service->quarantine($sourcePath, $quarantineId);
// Invalid scanner hook that doesn't return ScanResult
$invalidHook = function (string $filePath): array {
return ['status' => 'clean'];
};
expect(fn() => $this->service->scan($quarantineId, $invalidHook))
->toThrow(\InvalidArgumentException::class, 'Scanner hook must return ScanResult instance');
});
test('validates status transitions correctly', function () {
// Valid transitions
expect($this->service->canTransition(
QuarantineStatus::PENDING,
QuarantineStatus::SCANNING
))->toBeTrue();
expect($this->service->canTransition(
QuarantineStatus::SCANNING,
QuarantineStatus::APPROVED
))->toBeTrue();
expect($this->service->canTransition(
QuarantineStatus::SCANNING,
QuarantineStatus::REJECTED
))->toBeTrue();
// Invalid transitions
expect($this->service->canTransition(
QuarantineStatus::APPROVED,
QuarantineStatus::PENDING
))->toBeFalse();
expect($this->service->canTransition(
QuarantineStatus::REJECTED,
QuarantineStatus::APPROVED
))->toBeFalse();
});
test('cleans up expired quarantined files', function () {
// Create multiple files with different ages
$oldFile1 = '/tmp/source/old-file-1.txt';
$oldFile2 = '/tmp/source/old-file-2.txt';
$recentFile = '/tmp/source/recent-file.txt';
$this->fileStorage->put($oldFile1, 'old content 1');
$this->fileStorage->put($oldFile2, 'old content 2');
$this->fileStorage->put($recentFile, 'recent content');
$this->service->quarantine($oldFile1, 'old-1');
$this->service->quarantine($oldFile2, 'old-2');
$this->service->quarantine($recentFile, 'recent-1');
// Simulate old files by using expiry time in future
// (files older than 48 hours should be deleted)
$expiryTime = (new DateTimeImmutable())->modify('+48 hours');
$deletedCount = $this->service->cleanupExpired($expiryTime);
// All files should be deleted as they're "older" than expiry time
expect($deletedCount)->toBe(3);
expect($this->service->exists('old-1'))->toBeFalse();
expect($this->service->exists('old-2'))->toBeFalse();
expect($this->service->exists('recent-1'))->toBeFalse();
});
test('cleanup handles empty quarantine directory gracefully', function () {
// No quarantined files
$deletedCount = $this->service->cleanupExpired();
expect($deletedCount)->toBe(0);
});
test('creates quarantine directory if not exists', function () {
$sourcePath = '/tmp/source/first-file.txt';
$this->fileStorage->put($sourcePath, 'first quarantine');
// Quarantine directory doesn't exist yet
expect($this->fileStorage->exists('/tmp/quarantine'))->toBeFalse();
// Quarantine file - should create directory
$this->service->quarantine($sourcePath, 'first-quarantine');
// Directory should now exist
expect($this->fileStorage->exists('/tmp/quarantine'))->toBeTrue();
});
test('creates target directory if not exists during release', function () {
// Setup: quarantine a file
$sourcePath = '/tmp/source/release-target-test.txt';
$this->fileStorage->put($sourcePath, 'release content');
$quarantineId = 'release-dir-test';
$this->service->quarantine($sourcePath, $quarantineId);
// Target directory doesn't exist
$targetPath = '/tmp/new-target-dir/released-file.txt';
expect($this->fileStorage->exists('/tmp/new-target-dir'))->toBeFalse();
// Release - should create target directory
$this->service->release($quarantineId, $targetPath);
// Directory and file should exist
expect($this->fileStorage->exists('/tmp/new-target-dir'))->toBeTrue();
expect($this->fileStorage->exists($targetPath))->toBeTrue();
});

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\Contracts\LiveComponent;
use App\Framework\LiveComponents\Rendering\FragmentRenderer;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\View\TemplateRenderer;
describe('FragmentRenderer', function () {
beforeEach(function () {
$this->templateRenderer = Mockery::mock(TemplateRenderer::class);
$this->fragmentRenderer = new FragmentRenderer($this->templateRenderer);
});
afterEach(function () {
Mockery::close();
});
it('renders single fragment from template', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('counter:demo'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['count' => 5]));
$component->shouldReceive('getTemplateName')->andReturn('livecomponent-counter');
$fullHtml = <<<HTML
<div id="counter">
<div data-fragment="counter-display">
<span>Count: 5</span>
</div>
<div data-fragment="counter-controls">
<button>Increment</button>
</div>
</div>
HTML;
$this->templateRenderer
->shouldReceive('render')
->once()
->with('livecomponent-counter', ['count' => 5])
->andReturn($fullHtml);
$fragments = $this->fragmentRenderer->renderFragments(
$component,
['counter-display']
);
expect($fragments->isEmpty())->toBeFalse();
expect($fragments->has('counter-display'))->toBeTrue();
$displayFragment = $fragments->get('counter-display');
expect($displayFragment)->toContain('Count: 5');
expect($displayFragment)->toContain('span');
});
it('renders multiple fragments from template', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('counter:demo'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['count' => 10]));
$component->shouldReceive('getTemplateName')->andReturn('livecomponent-counter');
$fullHtml = <<<HTML
<div id="counter">
<div data-fragment="counter-display">
<span>Count: 10</span>
</div>
<div data-fragment="counter-controls">
<button>Increment</button>
<button>Decrement</button>
</div>
</div>
HTML;
$this->templateRenderer
->shouldReceive('render')
->once()
->andReturn($fullHtml);
$fragments = $this->fragmentRenderer->renderFragments(
$component,
['counter-display', 'counter-controls']
);
expect($fragments->count())->toBe(2);
expect($fragments->has('counter-display'))->toBeTrue();
expect($fragments->has('counter-controls'))->toBeTrue();
expect($fragments->get('counter-display'))->toContain('Count: 10');
expect($fragments->get('counter-controls'))->toContain('Increment');
expect($fragments->get('counter-controls'))->toContain('Decrement');
});
it('returns empty collection when fragments not found', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('counter:demo'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
$component->shouldReceive('getTemplateName')->andReturn('livecomponent-counter');
$fullHtml = '<div id="counter"><span>No fragments</span></div>';
$this->templateRenderer
->shouldReceive('render')
->once()
->andReturn($fullHtml);
$fragments = $this->fragmentRenderer->renderFragments(
$component,
['non-existent-fragment']
);
expect($fragments->isEmpty())->toBeTrue();
});
it('handles nested fragments correctly', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('form:demo'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
$component->shouldReceive('getTemplateName')->andReturn('livecomponent-form');
$fullHtml = <<<HTML
<form data-fragment="form-wrapper">
<div data-fragment="form-fields">
<input type="text" name="email" />
<div data-fragment="form-errors">
<span class="error">Invalid email</span>
</div>
</div>
</form>
HTML;
$this->templateRenderer
->shouldReceive('render')
->once()
->andReturn($fullHtml);
$fragments = $this->fragmentRenderer->renderFragments(
$component,
['form-fields', 'form-errors']
);
expect($fragments->count())->toBe(2);
expect($fragments->has('form-fields'))->toBeTrue();
expect($fragments->has('form-errors'))->toBeTrue();
// Nested fragment should still be extractable
expect($fragments->get('form-errors'))->toContain('Invalid email');
});
it('preserves HTML structure in fragments', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('card:demo'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['title' => 'Test Card']));
$component->shouldReceive('getTemplateName')->andReturn('livecomponent-card');
$fullHtml = <<<HTML
<div class="card">
<div data-fragment="card-header" class="card-header">
<h2>Test Card</h2>
<span class="badge">New</span>
</div>
<div data-fragment="card-body">
<p>Card content</p>
</div>
</div>
HTML;
$this->templateRenderer
->shouldReceive('render')
->once()
->andReturn($fullHtml);
$fragments = $this->fragmentRenderer->renderFragments(
$component,
['card-header']
);
$header = $fragments->get('card-header');
// Should preserve attributes and nested structure
expect($header)->toContain('class="card-header"');
expect($header)->toContain('<h2>Test Card</h2>');
expect($header)->toContain('class="badge"');
});
it('handles empty fragment list', function () {
$component = Mockery::mock(LiveComponent::class);
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('test:demo'));
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
$component->shouldReceive('getTemplateName')->andReturn('livecomponent-test');
// Should not call renderer if no fragments requested
$this->templateRenderer->shouldNotReceive('render');
$fragments = $this->fragmentRenderer->renderFragments($component, []);
expect($fragments->isEmpty())->toBeTrue();
});
});

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\LiveComponents\ValueObjects\CacheConfig;
describe('CacheConfig', function () {
it('creates basic cache config', function () {
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(10)
);
expect($config->enabled)->toBeTrue();
expect($config->ttl->toSeconds())->toBe(600);
expect($config->varyBy)->toBe([]);
expect($config->staleWhileRevalidate)->toBeFalse();
expect($config->staleWhileRevalidateTtl)->toBeNull();
});
it('creates config with varyBy parameters', function () {
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5),
varyBy: ['user_id', 'category', 'sort']
);
expect($config->varyBy)->toBe(['user_id', 'category', 'sort']);
});
it('creates config with stale-while-revalidate', function () {
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5),
staleWhileRevalidate: true,
staleWhileRevalidateTtl: Duration::fromMinutes(60)
);
expect($config->staleWhileRevalidate)->toBeTrue();
expect($config->staleWhileRevalidateTtl->toMinutes())->toBe(60);
});
it('creates disabled cache config', function () {
$config = new CacheConfig(
enabled: false,
ttl: Duration::fromMinutes(10)
);
expect($config->enabled)->toBeFalse();
});
it('creates from array', function () {
$data = [
'enabled' => true,
'ttl' => 600, // seconds
'varyBy' => ['filter', 'page'],
'staleWhileRevalidate' => true,
'staleWhileRevalidateTtl' => 3600,
];
$config = CacheConfig::fromArray($data);
expect($config->enabled)->toBeTrue();
expect($config->ttl->toSeconds())->toBe(600);
expect($config->varyBy)->toBe(['filter', 'page']);
expect($config->staleWhileRevalidate)->toBeTrue();
expect($config->staleWhileRevalidateTtl->toSeconds())->toBe(3600);
});
it('creates disabled config', function () {
$config = CacheConfig::disabled();
expect($config->enabled)->toBeFalse();
expect($config->ttl->toSeconds())->toBe(0);
expect($config->varyBy)->toBe([]);
expect($config->staleWhileRevalidate)->toBeFalse();
});
it('creates default config', function () {
$config = CacheConfig::default();
expect($config->enabled)->toBeTrue();
expect($config->ttl->toMinutes())->toBe(5); // Default 5 minutes
expect($config->varyBy)->toBe([]);
expect($config->staleWhileRevalidate)->toBeFalse();
});
it('checks if cache is enabled', function () {
$enabled = new CacheConfig(enabled: true, ttl: Duration::fromMinutes(5));
expect($enabled->isEnabled())->toBeTrue();
$disabled = new CacheConfig(enabled: false, ttl: Duration::fromMinutes(5));
expect($disabled->isEnabled())->toBeFalse();
});
it('checks if has variation', function () {
$withVariation = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5),
varyBy: ['user_id']
);
expect($withVariation->hasVariation())->toBeTrue();
$withoutVariation = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5),
varyBy: []
);
expect($withoutVariation->hasVariation())->toBeFalse();
});
it('checks if uses stale-while-revalidate', function () {
$withSWR = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5),
staleWhileRevalidate: true,
staleWhileRevalidateTtl: Duration::fromMinutes(60)
);
expect($withSWR->usesStaleWhileRevalidate())->toBeTrue();
$withoutSWR = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5),
staleWhileRevalidate: false
);
expect($withoutSWR->usesStaleWhileRevalidate())->toBeFalse();
});
it('gets effective TTL with SWR', function () {
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(5),
staleWhileRevalidate: true,
staleWhileRevalidateTtl: Duration::fromMinutes(60)
);
$effectiveTtl = $config->getEffectiveTtl();
// With SWR, effective TTL should be the SWR TTL
expect($effectiveTtl->toMinutes())->toBe(60);
});
it('gets effective TTL without SWR', function () {
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(10),
staleWhileRevalidate: false
);
$effectiveTtl = $config->getEffectiveTtl();
// Without SWR, effective TTL should be the regular TTL
expect($effectiveTtl->toMinutes())->toBe(10);
});
it('handles various TTL durations', function () {
$configs = [
Duration::fromSeconds(30),
Duration::fromMinutes(5),
Duration::fromMinutes(15),
Duration::fromHours(1),
Duration::fromHours(24),
Duration::fromDays(7),
];
foreach ($configs as $duration) {
$config = new CacheConfig(enabled: true, ttl: $duration);
expect($config->ttl)->toBe($duration);
}
});
it('supports multiple varyBy parameters', function () {
$varyByParams = [
'user_id',
'team_id',
'filter_category',
'filter_price_min',
'filter_price_max',
'sort_by',
'sort_order',
'page',
'per_page',
];
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(10),
varyBy: $varyByParams
);
expect($config->varyBy)->toBe($varyByParams);
expect($config->hasVariation())->toBeTrue();
});
it('converts to array', function () {
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromMinutes(10),
varyBy: ['filter', 'page'],
staleWhileRevalidate: true,
staleWhileRevalidateTtl: Duration::fromMinutes(60)
);
$array = $config->toArray();
expect($array)->toBe([
'enabled' => true,
'ttl' => 600,
'varyBy' => ['filter', 'page'],
'staleWhileRevalidate' => true,
'staleWhileRevalidateTtl' => 3600,
]);
});
it('creates config for short-lived cache', function () {
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromSeconds(10)
);
expect($config->ttl->toSeconds())->toBe(10);
});
it('creates config for long-lived cache', function () {
$config = new CacheConfig(
enabled: true,
ttl: Duration::fromDays(7)
);
expect($config->ttl->toDays())->toBe(7);
});
});

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Framework\LiveComponents\ValueObjects\ComponentId;
describe('ComponentId Value Object', function () {
it('creates from valid string format', function () {
$id = ComponentId::fromString('counter:demo');
expect($id->name)->toBe('counter');
expect($id->instanceId)->toBe('demo');
expect($id->toString())->toBe('counter:demo');
});
it('generates new component ID with unique instance', function () {
$id1 = ComponentId::generate('search');
$id2 = ComponentId::generate('search');
expect($id1->name)->toBe('search');
expect($id2->name)->toBe('search');
expect($id1->instanceId !== $id2->instanceId)->toBeTrue();
});
it('creates with specific instance ID', function () {
$id = ComponentId::create('modal', 'main-dialog');
expect($id->name)->toBe('modal');
expect($id->instanceId)->toBe('main-dialog');
expect($id->toString())->toBe('modal:main-dialog');
});
it('converts to string with __toString', function () {
$id = ComponentId::fromString('tabs:settings');
expect((string) $id)->toBe('tabs:settings');
});
it('checks equality correctly', function () {
$id1 = ComponentId::fromString('counter:demo');
$id2 = ComponentId::fromString('counter:demo');
$id3 = ComponentId::fromString('counter:other');
expect($id1->equals($id2))->toBeTrue();
expect($id1->equals($id3))->toBeFalse();
});
it('checks component name', function () {
$id = ComponentId::fromString('search:main-form');
expect($id->hasName('search'))->toBeTrue();
expect($id->hasName('counter'))->toBeFalse();
});
it('throws exception for empty ID', function () {
ComponentId::fromString('');
})->throws(InvalidArgumentException::class, 'Component ID cannot be empty');
it('throws exception for invalid format', function () {
ComponentId::fromString('invalid-format');
})->throws(InvalidArgumentException::class, 'Invalid component ID format');
it('throws exception for empty name', function () {
ComponentId::fromString(':instance');
})->throws(InvalidArgumentException::class, 'Component name cannot be empty');
it('throws exception for empty instance ID', function () {
ComponentId::fromString('component:');
})->throws(InvalidArgumentException::class, 'Instance ID cannot be empty');
it('throws exception for invalid name characters', function () {
ComponentId::create('invalid/name', 'instance');
})->throws(InvalidArgumentException::class, 'Invalid component name format');
it('accepts valid name formats', function (string $name) {
$id = ComponentId::create($name, 'test');
expect($id->name)->toBe($name);
})->with([
'lowercase' => 'counter',
'with-hyphens' => 'search-component',
'with_underscores' => 'data_table',
'with-numbers' => 'tab1',
'mixed' => 'live-component_v2',
]);
it('handles complex instance IDs', function () {
$instanceId = 'user-123_session-abc.def';
$id = ComponentId::create('profile', $instanceId);
expect($id->instanceId)->toBe($instanceId);
expect($id->toString())->toBe("profile:{$instanceId}");
});
it('parses component ID with multiple colons correctly', function () {
// Should only split on first colon
$id = ComponentId::fromString('component:instance:with:colons');
expect($id->name)->toBe('component');
expect($id->instanceId)->toBe('instance:with:colons');
});
});