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,81 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Debug;
use App\Application\Debug\DebugCodeEvaluator;
use App\Application\Debug\EvaluationResult;
describe('DebugCodeEvaluator', function () {
beforeEach(function () {
$this->evaluator = new DebugCodeEvaluator();
});
it('evaluates simple return statement', function () {
$result = $this->evaluator->evaluate('return 2 + 2;');
expect($result)->toBeInstanceOf(EvaluationResult::class);
expect($result->type)->toBe('int');
expect($result->rawValue)->toBe(4);
});
it('evaluates string concatenation', function () {
$result = $this->evaluator->evaluate('return "Hello" . " " . "World";');
expect($result->type)->toBe('string');
expect($result->rawValue)->toBe('Hello World');
});
it('evaluates array operations', function () {
$result = $this->evaluator->evaluate('return array_map(fn($x) => $x * 2, [1, 2, 3]);');
expect($result->type)->toBe('array');
expect($result->rawValue)->toBe([2, 4, 6]);
});
it('captures echo output', function () {
$result = $this->evaluator->evaluate('echo "Test output"; return null;');
expect($result->output)->toContain('Test output');
});
it('measures execution time', function () {
$result = $this->evaluator->evaluate('return 42;');
expect($result->executionTime)->toBeGreaterThan(0);
expect($result->executionTime)->toBeLessThan(1); // Should be very fast
});
it('throws exception for syntax errors', function () {
expect(fn () => $this->evaluator->evaluate('return invalid syntax'))
->toThrow(\ParseError::class);
});
it('throws exception for runtime errors', function () {
expect(fn () => $this->evaluator->evaluate('throw new \Exception("Test error");'))
->toThrow(\Exception::class, 'Test error');
});
it('formats array output', function () {
$result = $this->evaluator->evaluate('return ["name" => "Alice", "age" => 30];');
expect($result->output)->toContain('[');
expect($result->output)->toContain('name');
expect($result->output)->toContain('Alice');
});
it('handles null return value', function () {
$result = $this->evaluator->evaluate('$x = 5; return null;');
expect($result->type)->toBe('null');
expect($result->output)->toBe('(no output)');
});
it('evaluates variable assignments', function () {
$result = $this->evaluator->evaluate('$x = 10; $y = 20; return $x + $y;');
expect($result->rawValue)->toBe(30);
expect($result->type)->toBe('int');
});
});

View File

@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\Debug;
use App\Application\Debug\DebugCodeEvaluator;
use App\Application\Debug\DebugConsoleController;
use App\Application\Debug\DebugHistoryManager;
use App\Application\Debug\EvaluationResult;
use App\Framework\Config\Environment;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\ServerEnvironment;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
describe('DebugConsoleController', function () {
beforeEach(function () {
// Create test environment with development mode enabled
$this->environment = new Environment([
'APP_DEBUG' => true,
]);
// Mock DebugCodeEvaluator
$this->evaluator = new class extends DebugCodeEvaluator {
public function evaluate(string $code): EvaluationResult
{
// Simulate evaluation
return new EvaluationResult(
output: 'Test output',
type: 'string',
executionTime: 0.001,
rawValue: 'Test output'
);
}
};
// Mock DebugHistoryManager
$this->historyManager = new class extends DebugHistoryManager {
private array $history = [];
public function __construct()
{
// No parent constructor call - avoid cache dependency
}
public function addEntry(string $code, EvaluationResult $result): void
{
$this->history[] = [
'code' => $code,
'output' => $result->output,
'type' => $result->type,
'executionTime' => $result->executionTime,
'timestamp' => time(),
];
}
public function getHistory(int $limit = 50): array
{
return array_slice($this->history, 0, $limit);
}
public function clearHistory(): void
{
$this->history = [];
}
};
// Create mock request
$this->createRequest = function (string $method = 'GET', array $body = []) {
$serverEnv = new class extends ServerEnvironment {
public function __construct()
{
parent::__construct([
'HTTPS' => 'on',
'HTTP_HOST' => 'localhost',
'SERVER_PROTOCOL' => 'HTTP/1.1',
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/debug/console',
]);
}
};
return new Request(
method: Method::from($method),
uri: '/debug/console',
server: $serverEnv,
headers: new \App\Framework\Http\Headers([]),
cookies: new \App\Framework\Http\Cookies([]),
query: new \App\Framework\Http\QueryParameters([]),
parsedBody: $body
);
};
$this->controller = new DebugConsoleController(
$this->environment,
$this->evaluator,
$this->historyManager
);
});
it('returns console view in development mode', function () {
$request = ($this->createRequest)();
$result = $this->controller->console($request);
expect($result)->toBeInstanceOf(ViewResult::class);
expect($result->template)->toBe('debug/console');
expect($result->data)->toHaveKey('history');
expect($result->data)->toHaveKey('context');
});
it('executes PHP code successfully', function () {
$request = ($this->createRequest)('POST', ['code' => 'return 2 + 2;']);
$result = $this->controller->execute($request);
expect($result)->toBeInstanceOf(JsonResult::class);
expect($result->data['success'])->toBeTrue();
expect($result->data['result'])->toBe('Test output');
expect($result->data['type'])->toBe('string');
});
it('returns error when no code provided', function () {
$request = ($this->createRequest)('POST', []);
$result = $this->controller->execute($request);
expect($result)->toBeInstanceOf(JsonResult::class);
expect($result->data['success'])->toBeFalse();
expect($result->data['error'])->toBe('No code provided');
});
it('returns command history', function () {
// Add some history
$this->historyManager->addEntry('test code', new EvaluationResult(
output: 'output',
type: 'string',
executionTime: 0.001
));
$request = ($this->createRequest)();
$result = $this->controller->history($request);
expect($result)->toBeInstanceOf(JsonResult::class);
expect($result->data['history'])->toHaveCount(1);
});
it('clears command history', function () {
// Add history entry
$this->historyManager->addEntry('test', new EvaluationResult(
output: 'out',
type: 'string',
executionTime: 0.001
));
$request = ($this->createRequest)('POST');
$result = $this->controller->clearHistory($request);
expect($result)->toBeInstanceOf(JsonResult::class);
expect($result->data['success'])->toBeTrue();
expect($this->historyManager->getHistory())->toHaveCount(0);
});
it('throws exception when not in development mode', function () {
$prodEnvironment = new Environment(['APP_DEBUG' => false]);
$prodController = new DebugConsoleController(
$prodEnvironment,
$this->evaluator,
$this->historyManager
);
$request = ($this->createRequest)();
expect(fn () => $prodController->console($request))
->toThrow(\RuntimeException::class, 'Debug Console is only available in development mode');
});
it('includes context information', function () {
$request = ($this->createRequest)();
$result = $this->controller->console($request);
expect($result->data['context'])->toHaveKey('php_version');
expect($result->data['context'])->toHaveKey('memory_usage');
expect($result->data['context'])->toHaveKey('loaded_extensions');
});
});

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Application\GraphQL;
use App\Application\GraphQL\GraphQLPlaygroundController;
use App\Framework\Config\Environment;
use App\Framework\Config\EnvKey;
use App\Framework\Http\Method;
use App\Framework\Http\Request\HttpRequest;
use App\Framework\Http\ServerEnvironment;
use App\Framework\Router\Result\ViewResult;
describe('GraphQLPlaygroundController', function () {
beforeEach(function () {
// Create test environment with array
$this->environment = new Environment([
'APP_DEBUG' => true,
]);
// Mock HttpRequest
$this->createRequest = function (bool $isSecure = false, string $host = 'localhost') {
$serverEnv = new class($isSecure, $host) extends ServerEnvironment {
public function __construct(
private bool $isSecure,
private string $host
) {
parent::__construct([
'HTTPS' => $isSecure ? 'on' : 'off',
'HTTP_HOST' => $host,
'SERVER_PROTOCOL' => 'HTTP/1.1',
'REQUEST_METHOD' => 'GET',
'REQUEST_URI' => '/graphql/playground',
]);
}
public function isSecure(): bool
{
return $this->isSecure;
}
public function getHttpHost(): string
{
return $this->host;
}
};
return new HttpRequest(
method: Method::GET,
uri: '/graphql/playground',
server: $serverEnv,
headers: new \App\Framework\Http\Headers([]),
cookies: new \App\Framework\Http\Cookies([]),
query: new \App\Framework\Http\QueryParameters([]),
parsedBody: null
);
};
$this->controller = new GraphQLPlaygroundController($this->environment);
});
it('returns playground view in development mode', function () {
$request = ($this->createRequest)();
$result = $this->controller->playground($request);
expect($result)->toBeInstanceOf(ViewResult::class);
expect($result->template)->toBe('graphql/playground');
});
it('includes GraphQL endpoints in view data', function () {
$request = ($this->createRequest)(isSecure: false, host: 'localhost');
$result = $this->controller->playground($request);
expect($result->data['graphql_endpoint'])->toBe('http://localhost/graphql');
expect($result->data['graphql_ws_endpoint'])->toBe('ws://localhost/graphql');
expect($result->data['graphql_schema_endpoint'])->toBe('http://localhost/graphql/schema');
});
it('uses HTTPS/WSS for secure connections', function () {
$request = ($this->createRequest)(isSecure: true, host: 'example.com');
$result = $this->controller->playground($request);
expect($result->data['graphql_endpoint'])->toBe('https://example.com/graphql');
expect($result->data['graphql_ws_endpoint'])->toBe('wss://example.com/graphql');
expect($result->data['graphql_schema_endpoint'])->toBe('https://example.com/graphql/schema');
});
it('throws exception when not in development mode', function () {
// Create production environment with APP_DEBUG=false
$prodEnvironment = new Environment(['APP_DEBUG' => false]);
$prodController = new GraphQLPlaygroundController($prodEnvironment);
$request = ($this->createRequest)();
expect(fn() => $prodController->playground($request))
->toThrow(\RuntimeException::class, 'GraphQL Playground is only available in development mode');
});
it('works with different hostnames', function () {
$request = ($this->createRequest)(isSecure: true, host: 'api.example.com:8443');
$result = $this->controller->playground($request);
expect($result->data['graphql_endpoint'])->toBe('https://api.example.com:8443/graphql');
expect($result->data['graphql_ws_endpoint'])->toBe('wss://api.example.com:8443/graphql');
});
});

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
use App\Application\LiveComponents\Dashboard\FailedJobsState;
describe('FailedJobsState', function () {
it('creates empty state with default values', function () {
$state = FailedJobsState::empty();
expect($state->totalFailedJobs)->toBe(0);
expect($state->failedJobs)->toBe([]);
expect($state->statistics)->toBe([]);
expect($state->lastUpdated)->toBeString();
});
it('creates state from array', function () {
$data = [
'totalFailedJobs' => 15,
'failedJobs' => [
[
'id' => 'job-1',
'queue' => 'default',
'error' => 'Connection timeout',
],
],
'statistics' => [
'total_retries' => 42,
'avg_failure_time' => 123.45,
],
'lastUpdated' => '2024-01-15 12:00:00',
];
$state = FailedJobsState::fromArray($data);
expect($state->totalFailedJobs)->toBe(15);
expect($state->failedJobs)->toHaveCount(1);
expect($state->statistics)->toHaveKey('total_retries');
});
it('converts state to array', function () {
$failedJobs = [
[
'id' => 'job-1',
'queue' => 'default',
'job_type' => 'EmailJob',
'error' => 'SMTP connection failed',
'failed_at' => '2024-01-15 12:00:00',
'attempts' => 3,
],
];
$statistics = [
'total_retries' => 10,
'successful_retries' => 8,
];
$state = new FailedJobsState(
totalFailedJobs: 1,
failedJobs: $failedJobs,
statistics: $statistics,
lastUpdated: '2024-01-15 12:00:00'
);
$array = $state->toArray();
expect($array)->toHaveKey('totalFailedJobs');
expect($array)->toHaveKey('failedJobs');
expect($array)->toHaveKey('statistics');
expect($array['failedJobs'])->toHaveCount(1);
});
it('creates new state with updated failed jobs', function () {
$state = FailedJobsState::empty();
$failedJobs = [
[
'id' => 'job-1',
'error' => 'Database connection lost',
'attempts' => 5,
],
];
$statistics = [
'most_common_error' => 'Database connection lost',
'total_failures_today' => 10,
];
$updatedState = $state->withFailedJobs(
totalFailedJobs: 1,
failedJobs: $failedJobs,
statistics: $statistics
);
// Original unchanged
expect($state->totalFailedJobs)->toBe(0);
expect($state->failedJobs)->toBe([]);
// New state updated
expect($updatedState->totalFailedJobs)->toBe(1);
expect($updatedState->failedJobs)->toHaveCount(1);
expect($updatedState->statistics)->toHaveKey('most_common_error');
expect($updatedState->lastUpdated)->not->toBe($state->lastUpdated);
});
it('is immutable', function () {
$state = new FailedJobsState(
totalFailedJobs: 5,
failedJobs: [],
statistics: [],
lastUpdated: '2024-01-15 12:00:00'
);
$newState = $state->withFailedJobs(
totalFailedJobs: 10,
failedJobs: [],
statistics: []
);
// Original unchanged
expect($state->totalFailedJobs)->toBe(5);
// New instance
expect($newState)->not->toBe($state);
expect($newState->totalFailedJobs)->toBe(10);
});
it('handles empty failed jobs list', function () {
$state = FailedJobsState::empty()->withFailedJobs(
totalFailedJobs: 0,
failedJobs: [],
statistics: []
);
expect($state->failedJobs)->toBe([]);
expect($state->totalFailedJobs)->toBe(0);
expect($state->statistics)->toBe([]);
});
});

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
use App\Application\LiveComponents\Dashboard\QueueStatsState;
describe('QueueStatsState', function () {
it('creates empty state with default values', function () {
$state = QueueStatsState::empty();
expect($state->currentQueueSize)->toBe(0);
expect($state->totalJobs)->toBe(0);
expect($state->successfulJobs)->toBe(0);
expect($state->failedJobs)->toBe(0);
expect($state->successRate)->toBe(0.0);
expect($state->avgExecutionTimeMs)->toBe(0.0);
expect($state->lastUpdated)->toBeString();
});
it('creates state from array', function () {
$data = [
'currentQueueSize' => 42,
'totalJobs' => 1000,
'successfulJobs' => 950,
'failedJobs' => 50,
'successRate' => 95.0,
'avgExecutionTimeMs' => 123.45,
'lastUpdated' => '2024-01-15 12:00:00',
];
$state = QueueStatsState::fromArray($data);
expect($state->currentQueueSize)->toBe(42);
expect($state->totalJobs)->toBe(1000);
expect($state->successfulJobs)->toBe(950);
expect($state->failedJobs)->toBe(50);
expect($state->successRate)->toBe(95.0);
expect($state->avgExecutionTimeMs)->toBe(123.45);
expect($state->lastUpdated)->toBe('2024-01-15 12:00:00');
});
it('converts state to array', function () {
$state = new QueueStatsState(
currentQueueSize: 10,
totalJobs: 100,
successfulJobs: 90,
failedJobs: 10,
successRate: 90.0,
avgExecutionTimeMs: 50.5,
lastUpdated: '2024-01-15 12:00:00'
);
$array = $state->toArray();
expect($array)->toBe([
'currentQueueSize' => 10,
'totalJobs' => 100,
'successfulJobs' => 90,
'failedJobs' => 10,
'successRate' => 90.0,
'avgExecutionTimeMs' => 50.5,
'lastUpdated' => '2024-01-15 12:00:00',
]);
});
it('creates new state with updated stats', function () {
$state = QueueStatsState::empty();
$updatedState = $state->withStats(
currentQueueSize: 5,
totalJobs: 50,
successfulJobs: 45,
failedJobs: 5,
successRate: 90.0,
avgExecutionTimeMs: 75.0
);
// Original state unchanged (immutable)
expect($state->currentQueueSize)->toBe(0);
expect($state->totalJobs)->toBe(0);
// New state has updated values
expect($updatedState->currentQueueSize)->toBe(5);
expect($updatedState->totalJobs)->toBe(50);
expect($updatedState->successfulJobs)->toBe(45);
expect($updatedState->failedJobs)->toBe(5);
expect($updatedState->successRate)->toBe(90.0);
expect($updatedState->avgExecutionTimeMs)->toBe(75.0);
expect($updatedState->lastUpdated)->not->toBe($state->lastUpdated);
});
it('handles zero division in success rate gracefully', function () {
$state = new QueueStatsState(
currentQueueSize: 0,
totalJobs: 0,
successfulJobs: 0,
failedJobs: 0,
successRate: 0.0,
avgExecutionTimeMs: 0.0,
lastUpdated: '2024-01-15 12:00:00'
);
expect($state->successRate)->toBe(0.0);
});
it('is immutable', function () {
$state = new QueueStatsState(
currentQueueSize: 10,
totalJobs: 100,
successfulJobs: 90,
failedJobs: 10,
successRate: 90.0,
avgExecutionTimeMs: 50.0,
lastUpdated: '2024-01-15 12:00:00'
);
$newState = $state->withStats(
currentQueueSize: 20,
totalJobs: 200,
successfulJobs: 180,
failedJobs: 20,
successRate: 90.0,
avgExecutionTimeMs: 60.0
);
// Original unchanged
expect($state->currentQueueSize)->toBe(10);
expect($state->totalJobs)->toBe(100);
// New instance created
expect($newState)->not->toBe($state);
expect($newState->currentQueueSize)->toBe(20);
expect($newState->totalJobs)->toBe(200);
});
});

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
use App\Application\LiveComponents\Dashboard\SchedulerState;
describe('SchedulerState', function () {
it('creates empty state with default values', function () {
$state = SchedulerState::empty();
expect($state->totalScheduledTasks)->toBe(0);
expect($state->dueTasks)->toBe(0);
expect($state->upcomingTasks)->toBe([]);
expect($state->nextExecution)->toBeNull();
expect($state->statistics)->toBe([]);
expect($state->lastUpdated)->toBeString();
});
it('creates state from array', function () {
$data = [
'totalScheduledTasks' => 10,
'dueTasks' => 2,
'upcomingTasks' => [
[
'id' => 'task-1',
'schedule_type' => 'cron',
'next_run' => '2024-01-15 13:00:00',
],
],
'nextExecution' => '2024-01-15 13:00:00',
'statistics' => [
'total_executions_today' => 50,
],
'lastUpdated' => '2024-01-15 12:00:00',
];
$state = SchedulerState::fromArray($data);
expect($state->totalScheduledTasks)->toBe(10);
expect($state->dueTasks)->toBe(2);
expect($state->upcomingTasks)->toHaveCount(1);
expect($state->nextExecution)->toBe('2024-01-15 13:00:00');
});
it('converts state to array', function () {
$upcomingTasks = [
[
'id' => 'task-1',
'schedule_type' => 'interval',
'next_run' => '2024-01-15 13:00:00',
'next_run_relative' => 'in 1 hour',
'is_due' => false,
],
];
$statistics = [
'successful_executions' => 45,
'failed_executions' => 5,
];
$state = new SchedulerState(
totalScheduledTasks: 5,
dueTasks: 1,
upcomingTasks: $upcomingTasks,
nextExecution: '2024-01-15 13:00:00',
statistics: $statistics,
lastUpdated: '2024-01-15 12:00:00'
);
$array = $state->toArray();
expect($array)->toHaveKey('totalScheduledTasks');
expect($array)->toHaveKey('dueTasks');
expect($array)->toHaveKey('upcomingTasks');
expect($array)->toHaveKey('nextExecution');
expect($array)->toHaveKey('statistics');
expect($array['upcomingTasks'])->toHaveCount(1);
});
it('creates new state with updated scheduler data', function () {
$state = SchedulerState::empty();
$upcomingTasks = [
[
'id' => 'backup-task',
'schedule_type' => 'cron',
'next_run' => '2024-01-15 02:00:00',
'is_due' => true,
],
];
$statistics = [
'tasks_executed_today' => 20,
'average_execution_time' => 1.5,
];
$updatedState = $state->withSchedulerData(
totalScheduledTasks: 3,
dueTasks: 1,
upcomingTasks: $upcomingTasks,
nextExecution: '2024-01-15 02:00:00',
statistics: $statistics
);
// Original unchanged
expect($state->totalScheduledTasks)->toBe(0);
expect($state->upcomingTasks)->toBe([]);
expect($state->nextExecution)->toBeNull();
// New state updated
expect($updatedState->totalScheduledTasks)->toBe(3);
expect($updatedState->dueTasks)->toBe(1);
expect($updatedState->upcomingTasks)->toHaveCount(1);
expect($updatedState->nextExecution)->toBe('2024-01-15 02:00:00');
expect($updatedState->statistics)->toHaveKey('tasks_executed_today');
expect($updatedState->lastUpdated)->not->toBe($state->lastUpdated);
});
it('is immutable', function () {
$state = new SchedulerState(
totalScheduledTasks: 10,
dueTasks: 2,
upcomingTasks: [],
nextExecution: '2024-01-15 12:00:00',
statistics: [],
lastUpdated: '2024-01-15 11:00:00'
);
$newState = $state->withSchedulerData(
totalScheduledTasks: 15,
dueTasks: 3,
upcomingTasks: [],
nextExecution: '2024-01-15 13:00:00',
statistics: []
);
// Original unchanged
expect($state->totalScheduledTasks)->toBe(10);
expect($state->dueTasks)->toBe(2);
// New instance
expect($newState)->not->toBe($state);
expect($newState->totalScheduledTasks)->toBe(15);
expect($newState->dueTasks)->toBe(3);
});
it('handles null next execution', function () {
$state = SchedulerState::empty()->withSchedulerData(
totalScheduledTasks: 0,
dueTasks: 0,
upcomingTasks: [],
nextExecution: null,
statistics: []
);
expect($state->nextExecution)->toBeNull();
expect($state->totalScheduledTasks)->toBe(0);
});
it('handles empty upcoming tasks', function () {
$state = SchedulerState::empty()->withSchedulerData(
totalScheduledTasks: 0,
dueTasks: 0,
upcomingTasks: [],
nextExecution: null,
statistics: []
);
expect($state->upcomingTasks)->toBe([]);
expect($state->statistics)->toBe([]);
});
});

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
use App\Application\LiveComponents\Dashboard\WorkerHealthState;
describe('WorkerHealthState', function () {
it('creates empty state with default values', function () {
$state = WorkerHealthState::empty();
expect($state->activeWorkers)->toBe(0);
expect($state->totalWorkers)->toBe(0);
expect($state->jobsInProgress)->toBe(0);
expect($state->workerDetails)->toBe([]);
expect($state->lastUpdated)->toBeString();
});
it('creates state from array', function () {
$data = [
'activeWorkers' => 3,
'totalWorkers' => 5,
'jobsInProgress' => 7,
'workerDetails' => [
[
'id' => 'worker-1',
'hostname' => 'server-01',
'healthy' => true,
],
],
'lastUpdated' => '2024-01-15 12:00:00',
];
$state = WorkerHealthState::fromArray($data);
expect($state->activeWorkers)->toBe(3);
expect($state->totalWorkers)->toBe(5);
expect($state->jobsInProgress)->toBe(7);
expect($state->workerDetails)->toHaveCount(1);
expect($state->workerDetails[0]['id'])->toBe('worker-1');
});
it('converts state to array', function () {
$workerDetails = [
[
'id' => 'worker-1',
'hostname' => 'server-01',
'process_id' => 12345,
'healthy' => true,
'jobs' => 2,
'max_jobs' => 10,
],
];
$state = new WorkerHealthState(
activeWorkers: 1,
totalWorkers: 3,
jobsInProgress: 2,
workerDetails: $workerDetails,
lastUpdated: '2024-01-15 12:00:00'
);
$array = $state->toArray();
expect($array)->toHaveKey('activeWorkers');
expect($array)->toHaveKey('totalWorkers');
expect($array)->toHaveKey('jobsInProgress');
expect($array)->toHaveKey('workerDetails');
expect($array['workerDetails'])->toHaveCount(1);
});
it('creates new state with updated worker health', function () {
$state = WorkerHealthState::empty();
$workerDetails = [
[
'id' => 'worker-1',
'hostname' => 'server-01',
'healthy' => true,
'jobs' => 5,
],
[
'id' => 'worker-2',
'hostname' => 'server-02',
'healthy' => false,
'jobs' => 0,
],
];
$updatedState = $state->withWorkerHealth(
activeWorkers: 2,
totalWorkers: 2,
jobsInProgress: 5,
workerDetails: $workerDetails
);
// Original unchanged
expect($state->activeWorkers)->toBe(0);
expect($state->workerDetails)->toBe([]);
// New state updated
expect($updatedState->activeWorkers)->toBe(2);
expect($updatedState->totalWorkers)->toBe(2);
expect($updatedState->jobsInProgress)->toBe(5);
expect($updatedState->workerDetails)->toHaveCount(2);
expect($updatedState->lastUpdated)->not->toBe($state->lastUpdated);
});
it('is immutable', function () {
$state = new WorkerHealthState(
activeWorkers: 5,
totalWorkers: 10,
jobsInProgress: 15,
workerDetails: [],
lastUpdated: '2024-01-15 12:00:00'
);
$newState = $state->withWorkerHealth(
activeWorkers: 6,
totalWorkers: 10,
jobsInProgress: 20,
workerDetails: []
);
// Original unchanged
expect($state->activeWorkers)->toBe(5);
expect($state->jobsInProgress)->toBe(15);
// New instance
expect($newState)->not->toBe($state);
expect($newState->activeWorkers)->toBe(6);
expect($newState->jobsInProgress)->toBe(20);
});
it('handles empty worker details array', function () {
$state = WorkerHealthState::empty()->withWorkerHealth(
activeWorkers: 0,
totalWorkers: 0,
jobsInProgress: 0,
workerDetails: []
);
expect($state->workerDetails)->toBe([]);
expect($state->activeWorkers)->toBe(0);
});
});

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
use App\Application\LiveComponents\LiveComponentStateManager;
use App\Application\LiveComponents\ShoppingCart\ShoppingCartState;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\StateManagement\InMemoryStateManager;
use App\Framework\StateManagement\StateManagerStatistics;
describe('LiveComponentStateManager', function () {
beforeEach(function () {
// Create type-safe StateManager for ShoppingCartState
$this->stateManager = InMemoryStateManager::for(ShoppingCartState::class);
$this->manager = new LiveComponentStateManager($this->stateManager);
$this->componentId = ComponentId::create('shopping-cart', 'user-123');
});
it('stores and retrieves component state', function () {
$cartState = ShoppingCartState::fromArray([
'items' => [
['id' => '1', 'name' => 'Product A', 'price' => 29.99, 'quantity' => 2],
],
'discount_code' => 'SAVE10',
'discount_percentage' => 10
]);
$this->manager->setComponentState($this->componentId, $cartState);
$retrieved = $this->manager->getComponentState($this->componentId);
expect($retrieved)->toBeInstanceOf(ShoppingCartState::class);
expect($retrieved->discountCode)->toBe('SAVE10');
expect($retrieved->discountPercentage)->toBe(10);
expect(count($retrieved->items))->toBe(1);
});
it('preserves type safety throughout', function () {
$cartState = ShoppingCartState::fromArray([
'items' => [
['id' => '1', 'name' => 'Product A', 'price' => 19.99, 'quantity' => 1],
],
'discount_code' => 'TEST',
'discount_percentage' => 5
]);
$this->manager->setComponentState($this->componentId, $cartState);
$retrieved = $this->manager->getComponentState($this->componentId);
// Type is preserved - not mixed or LiveComponentState, but ShoppingCartState
expect($retrieved)->toBeInstanceOf(ShoppingCartState::class);
expect($retrieved->items)->toBeArray();
expect($retrieved->discountCode)->toBeString();
});
it('checks if component state exists', function () {
expect($this->manager->hasComponentState($this->componentId))->toBeFalse();
$cartState = ShoppingCartState::fromArray([
'items' => [],
'discount_code' => null,
'discount_percentage' => 0
]);
$this->manager->setComponentState($this->componentId, $cartState);
expect($this->manager->hasComponentState($this->componentId))->toBeTrue();
});
it('removes component state', function () {
$cartState = ShoppingCartState::fromArray([
'items' => [],
'discount_code' => null,
'discount_percentage' => 0
]);
$this->manager->setComponentState($this->componentId, $cartState);
expect($this->manager->hasComponentState($this->componentId))->toBeTrue();
$this->manager->removeComponentState($this->componentId);
expect($this->manager->hasComponentState($this->componentId))->toBeFalse();
expect($this->manager->getComponentState($this->componentId))->toBeNull();
});
it('atomically updates component state', function () {
$initialCart = ShoppingCartState::fromArray([
'items' => [
['id' => '1', 'name' => 'Product A', 'price' => 29.99, 'quantity' => 1],
],
'discount_code' => 'SAVE10',
'discount_percentage' => 10
]);
$this->manager->setComponentState($this->componentId, $initialCart);
$updatedCart = $this->manager->updateComponentState(
$this->componentId,
function ($state) {
return $state ? $state->withDiscountCode('SAVE20', 20) : ShoppingCartState::fromArray([]);
}
);
expect($updatedCart)->toBeInstanceOf(ShoppingCartState::class);
expect($updatedCart->discountCode)->toBe('SAVE20');
expect($updatedCart->discountPercentage)->toBe(20);
expect(count($updatedCart->items))->toBe(1);
});
it('handles null state in atomic update', function () {
$newCart = $this->manager->updateComponentState(
$this->componentId,
function ($state) {
return $state ?? ShoppingCartState::fromArray([
'items' => [
['id' => '2', 'name' => 'New Product', 'price' => 15.50, 'quantity' => 1],
],
'discount_code' => 'FIRST',
'discount_percentage' => 5
]);
}
);
expect($newCart)->toBeInstanceOf(ShoppingCartState::class);
expect($newCart->discountCode)->toBe('FIRST');
expect(count($newCart->items))->toBe(1);
});
it('respects TTL for state expiration', function () {
$cartState = ShoppingCartState::fromArray([
'items' => [],
'discount_code' => null,
'discount_percentage' => 0
]);
// Store with very short TTL
$this->manager->setComponentState(
$this->componentId,
$cartState,
Duration::fromSeconds(1)
);
expect($this->manager->hasComponentState($this->componentId))->toBeTrue();
// Wait for expiration
sleep(2);
// State should be expired (for TTL-aware implementations)
// Note: InMemoryStateManager doesn't enforce TTL, but the interface supports it
});
it('retrieves all component states', function () {
$cart1 = ShoppingCartState::fromArray([
'items' => [
['id' => '1', 'name' => 'Product A', 'price' => 29.99, 'quantity' => 1],
],
'discount_code' => 'SAVE10',
'discount_percentage' => 10
]);
$cart2 = ShoppingCartState::fromArray([
'items' => [
['id' => '2', 'name' => 'Product B', 'price' => 15.50, 'quantity' => 2],
],
'discount_code' => 'SAVE20',
'discount_percentage' => 20
]);
$componentId1 = ComponentId::create('shopping-cart', 'user-123');
$componentId2 = ComponentId::create('shopping-cart', 'user-456');
$this->manager->setComponentState($componentId1, $cart1);
$this->manager->setComponentState($componentId2, $cart2);
$allStates = $this->manager->getAllComponentStates();
expect($allStates)->toBeArray();
expect(count($allStates))->toBeGreaterThanOrEqual(2);
});
it('clears all component states', function () {
$cart1 = ShoppingCartState::fromArray([
'items' => [],
'discount_code' => null,
'discount_percentage' => 0
]);
$cart2 = ShoppingCartState::fromArray([
'items' => [],
'discount_code' => 'TEST',
'discount_percentage' => 5
]);
$componentId1 = ComponentId::create('shopping-cart', 'user-123');
$componentId2 = ComponentId::create('shopping-cart', 'user-456');
$this->manager->setComponentState($componentId1, $cart1);
$this->manager->setComponentState($componentId2, $cart2);
$this->manager->clearAllComponentStates();
expect($this->manager->hasComponentState($componentId1))->toBeFalse();
expect($this->manager->hasComponentState($componentId2))->toBeFalse();
});
it('provides statistics about state operations', function () {
$cartState = ShoppingCartState::fromArray([
'items' => [],
'discount_code' => null,
'discount_percentage' => 0
]);
$this->manager->setComponentState($this->componentId, $cartState);
$this->manager->getComponentState($this->componentId);
$this->manager->hasComponentState($this->componentId);
$stats = $this->manager->getStatistics();
expect($stats)->toBeInstanceOf(StateManagerStatistics::class);
expect($stats->totalKeys)->toBeGreaterThanOrEqual(1);
expect($stats->setCount)->toBeGreaterThanOrEqual(1);
expect($stats->hitCount)->toBeGreaterThanOrEqual(1);
});
it('can be created via factory method', function () {
$stateManager = InMemoryStateManager::for(ShoppingCartState::class);
$manager = LiveComponentStateManager::from($stateManager);
expect($manager)->toBeInstanceOf(LiveComponentStateManager::class);
});
it('generates consistent cache keys', function () {
$componentId1 = ComponentId::fromString('shopping-cart:user-123');
$componentId2 = ComponentId::fromString('shopping-cart:user-123');
$componentId3 = ComponentId::fromString('shopping-cart:user-456');
$cartState = ShoppingCartState::fromArray([
'items' => [],
'discount_code' => null,
'discount_percentage' => 0
]);
$this->manager->setComponentState($componentId1, $cartState);
// Same component ID should retrieve same state
expect($this->manager->hasComponentState($componentId2))->toBeTrue();
// Different instance ID should not have state
expect($this->manager->hasComponentState($componentId3))->toBeFalse();
});
it('handles complex state transformations', function () {
$initialCart = ShoppingCartState::fromArray([
'items' => [
['id' => '1', 'name' => 'Product A', 'price' => 29.99, 'quantity' => 1],
],
'discount_code' => null,
'discount_percentage' => 0
]);
$this->manager->setComponentState($this->componentId, $initialCart);
// Multiple transformations
$updatedCart = $this->manager->updateComponentState(
$this->componentId,
fn($state) => $state->withDiscountCode('SAVE10', 10)
);
$updatedCart = $this->manager->updateComponentState(
$this->componentId,
function($state) {
return ShoppingCartState::fromArray([
'items' => array_merge($state->items, [
['id' => '2', 'name' => 'Product B', 'price' => 15.50, 'quantity' => 2]
]),
'discount_code' => $state->discountCode,
'discount_percentage' => $state->discountPercentage
]);
}
);
expect(count($updatedCart->items))->toBe(2);
expect($updatedCart->discountCode)->toBe('SAVE10');
});
});