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