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,181 @@
<?php
declare(strict_types=1);
/**
* LiveComponent Testing Example: CounterComponent
*
* Demonstrates usage of Pest helper functions for LiveComponent testing:
* - mountComponent() - Mount component with initial state
* - callAction() - Execute component actions
* - callActionWithFragments() - Execute actions with fragment updates
* - Custom Expectations: toHaveState, toContainHtml, toHaveDispatchedEvent
*/
use function Pest\LiveComponents\mountComponent;
use function Pest\LiveComponents\callAction;
describe('CounterComponent', function () {
it('renders initial counter state', function () {
$component = mountComponent('counter:test', ['count' => 0]);
expect($component['html'])
->toContainHtml('Count: 0');
expect($component['state'])
->toHaveState(['count' => 0, 'lastUpdate' => null]);
});
it('renders with custom initial count', function () {
$component = mountComponent('counter:test', ['count' => 42]);
expect($component['html'])
->toContainHtml('Count: 42');
expect($component['state'])
->toHaveStateKey('count', 42);
});
it('increments counter on action', function () {
$component = mountComponent('counter:test', ['count' => 5]);
$result = callAction($component, 'increment');
expect($result['state'])
->toHaveStateKey('count', 6);
expect($result['html'])
->toContainHtml('Count: 6');
});
it('decrements counter on action', function () {
$component = mountComponent('counter:test', ['count' => 5]);
$result = callAction($component, 'decrement');
expect($result['state'])
->toHaveStateKey('count', 4);
expect($result['html'])
->toContainHtml('Count: 4');
});
it('prevents negative counts on decrement', function () {
$component = mountComponent('counter:test', ['count' => 0]);
$result = callAction($component, 'decrement');
expect($result['state'])
->toHaveStateKey('count', 0); // Should stay at 0, not go negative
});
it('resets counter to zero', function () {
$component = mountComponent('counter:test', ['count' => 42]);
$result = callAction($component, 'reset');
expect($result['state'])
->toHaveStateKey('count', 0);
});
it('adds custom amount to counter', function () {
$component = mountComponent('counter:test', ['count' => 10]);
$result = callAction($component, 'addAmount', ['amount' => 15]);
expect($result['state'])
->toHaveStateKey('count', 25);
});
it('prevents negative counts when adding negative amount', function () {
$component = mountComponent('counter:test', ['count' => 5]);
$result = callAction($component, 'addAmount', ['amount' => -10]);
expect($result['state'])
->toHaveStateKey('count', 0); // Should be capped at 0
});
it('dispatches counter:changed event on increment', function () {
$component = mountComponent('counter:test', ['count' => 5]);
$result = callAction($component, 'increment');
expect($result['events'])
->toHaveDispatchedEvent('counter:changed');
});
it('dispatches counter:changed event with correct data', function () {
$component = mountComponent('counter:test', ['count' => 5]);
$result = callAction($component, 'increment');
expect($result['events'])
->toHaveDispatchedEventWithData('counter:changed', [
'component_id' => 'counter:test',
'old_value' => 5,
'new_value' => 6,
'change' => '+1',
]);
});
it('dispatches milestone event at multiples of 10', function () {
$component = mountComponent('counter:test', ['count' => 9]);
$result = callAction($component, 'increment');
expect($result['events'])
->toHaveDispatchedEvent('counter:milestone');
});
it('does not dispatch milestone event when not at multiple of 10', function () {
$component = mountComponent('counter:test', ['count' => 8]);
$result = callAction($component, 'increment');
// Should have counter:changed but not milestone
expect($result['events'])->toHaveDispatchedEvent('counter:changed');
$hasMilestone = false;
foreach ($result['events'] as $event) {
if ($event->name === 'counter:milestone') {
$hasMilestone = true;
}
}
expect($hasMilestone)->toBeFalse();
});
it('dispatches reset event on reset action', function () {
$component = mountComponent('counter:test', ['count' => 42]);
$result = callAction($component, 'reset');
expect($result['events'])
->toHaveDispatchedEventWithData('counter:reset', [
'component_id' => 'counter:test',
'previous_value' => 42,
]);
});
it('maintains component identity across actions', function () {
$component = mountComponent('counter:test');
$result1 = callAction($component, 'increment');
$result2 = callAction($result1, 'increment');
expect($result2['componentId'])->toBe('counter:test');
expect($result2['state']['count'])->toBe(2);
});
it('chains multiple actions correctly', function () {
$component = mountComponent('counter:test', ['count' => 0]);
$result = callAction($component, 'increment');
$result = callAction($result, 'increment');
$result = callAction($result, 'increment');
$result = callAction($result, 'decrement');
expect($result['state']['count'])->toBe(2);
});
});

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
use App\Application\LiveComponents\Dashboard\FailedJobsListComponent;
use App\Application\LiveComponents\Dashboard\FailedJobsState;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Queue\Services\DeadLetterManager;
use App\Framework\Queue\Entities\DeadLetterJob;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Core\ValueObjects\Timestamp;
describe('FailedJobsListComponent', function () {
beforeEach(function () {
$this->deadLetterManager = Mockery::mock(DeadLetterManager::class);
$this->componentId = ComponentId::create('failed-jobs', 'test');
$this->initialState = FailedJobsState::empty();
});
afterEach(function () {
Mockery::close();
});
it('polls and updates state with failed jobs', function () {
$failedJob1 = new DeadLetterJob(
id: JobId::generate(),
queueName: new QueueName('default'),
jobType: 'SendEmailJob',
payload: ['email' => 'test@example.com'],
exception: 'Connection timeout',
failedAt: Timestamp::now(),
attempts: 3
);
$failedJob2 = new DeadLetterJob(
id: JobId::generate(),
queueName: new QueueName('high-priority'),
jobType: 'ProcessPaymentJob',
payload: ['amount' => 100],
exception: 'Payment gateway error',
failedAt: Timestamp::now(),
attempts: 5
);
$this->deadLetterManager->shouldReceive('getFailedJobs')
->once()
->with(50)
->andReturn([$failedJob1, $failedJob2]);
$component = new FailedJobsListComponent(
id: $this->componentId,
state: $this->initialState,
deadLetterManager: $this->deadLetterManager
);
$newState = $component->poll();
expect($newState)->toBeInstanceOf(FailedJobsState::class);
expect($newState->totalFailedJobs)->toBe(2);
expect($newState->failedJobs)->toHaveCount(2);
expect($newState->failedJobs[0]['job_type'])->toBe('SendEmailJob');
expect($newState->failedJobs[0]['queue'])->toBe('default');
expect($newState->failedJobs[0]['attempts'])->toBe(3);
});
it('handles retry job action successfully', function () {
$jobId = JobId::generate();
$this->deadLetterManager->shouldReceive('retryJob')
->once()
->with($jobId->toString())
->andReturn(true);
// After retry, poll returns updated state
$this->deadLetterManager->shouldReceive('getFailedJobs')
->once()
->with(50)
->andReturn([]);
$component = new FailedJobsListComponent(
id: $this->componentId,
state: $this->initialState,
deadLetterManager: $this->deadLetterManager
);
$newState = $component->retryJob($jobId->toString());
expect($newState)->toBeInstanceOf(FailedJobsState::class);
expect($newState->totalFailedJobs)->toBe(0); // Job was retried
});
it('handles retry job action failure', function () {
$jobId = JobId::generate();
$this->deadLetterManager->shouldReceive('retryJob')
->once()
->with($jobId->toString())
->andReturn(false);
// Still poll for current state
$this->deadLetterManager->shouldReceive('getFailedJobs')
->once()
->andReturn([]);
$component = new FailedJobsListComponent(
id: $this->componentId,
state: $this->initialState,
deadLetterManager: $this->deadLetterManager
);
$newState = $component->retryJob($jobId->toString());
expect($newState)->toBeInstanceOf(FailedJobsState::class);
});
it('handles delete job action successfully', function () {
$jobId = JobId::generate();
$this->deadLetterManager->shouldReceive('deleteJob')
->once()
->with($jobId->toString())
->andReturn(true);
// After delete, poll returns updated state
$this->deadLetterManager->shouldReceive('getFailedJobs')
->once()
->with(50)
->andReturn([]);
$component = new FailedJobsListComponent(
id: $this->componentId,
state: $this->initialState,
deadLetterManager: $this->deadLetterManager
);
$newState = $component->deleteJob($jobId->toString());
expect($newState)->toBeInstanceOf(FailedJobsState::class);
expect($newState->totalFailedJobs)->toBe(0);
});
it('dispatches events on retry success', function () {
$jobId = JobId::generate();
$eventDispatcher = Mockery::mock(ComponentEventDispatcher::class);
$this->deadLetterManager->shouldReceive('retryJob')
->once()
->andReturn(true);
$this->deadLetterManager->shouldReceive('getFailedJobs')
->once()
->andReturn([]);
$eventDispatcher->shouldReceive('dispatch')
->once()
->with('failed-jobs:retry-success', ['jobId' => $jobId->toString()]);
$component = new FailedJobsListComponent(
id: $this->componentId,
state: $this->initialState,
deadLetterManager: $this->deadLetterManager
);
$component->retryJob($jobId->toString(), $eventDispatcher);
});
it('has correct poll interval', function () {
$component = new FailedJobsListComponent(
id: $this->componentId,
state: $this->initialState,
deadLetterManager: $this->deadLetterManager
);
expect($component->getPollInterval())->toBe(10000);
});
it('returns correct render data', function () {
$failedJobs = [
[
'id' => 'job-1',
'queue' => 'default',
'job_type' => 'EmailJob',
'error' => 'Connection failed',
'failed_at' => '2024-01-15 12:00:00',
'attempts' => 3,
],
];
$state = new FailedJobsState(
totalFailedJobs: 1,
failedJobs: $failedJobs,
statistics: ['total_retries' => 5],
lastUpdated: '2024-01-15 12:00:00'
);
$component = new FailedJobsListComponent(
id: $this->componentId,
state: $state,
deadLetterManager: $this->deadLetterManager
);
$renderData = $component->getRenderData();
expect($renderData->templatePath)->toBe('livecomponent-failed-jobs-list');
expect($renderData->data)->toHaveKey('componentId');
expect($renderData->data)->toHaveKey('pollInterval');
expect($renderData->data['pollInterval'])->toBe(10000);
expect($renderData->data['totalFailedJobs'])->toBe(1);
expect($renderData->data['failedJobs'])->toHaveCount(1);
});
it('handles empty failed jobs list', function () {
$this->deadLetterManager->shouldReceive('getFailedJobs')
->once()
->with(50)
->andReturn([]);
$component = new FailedJobsListComponent(
id: $this->componentId,
state: $this->initialState,
deadLetterManager: $this->deadLetterManager
);
$newState = $component->poll();
expect($newState->totalFailedJobs)->toBe(0);
expect($newState->failedJobs)->toBe([]);
});
it('truncates payload preview to 100 characters', function () {
$longPayload = str_repeat('a', 200);
$failedJob = new DeadLetterJob(
id: JobId::generate(),
queueName: new QueueName('default'),
jobType: 'LongJob',
payload: ['data' => $longPayload],
exception: 'Error',
failedAt: Timestamp::now(),
attempts: 1
);
$this->deadLetterManager->shouldReceive('getFailedJobs')
->once()
->with(50)
->andReturn([$failedJob]);
$component = new FailedJobsListComponent(
id: $this->componentId,
state: $this->initialState,
deadLetterManager: $this->deadLetterManager
);
$newState = $component->poll();
$payloadPreview = $newState->failedJobs[0]['payload_preview'];
expect(strlen($payloadPreview))->toBeLessThanOrEqual(103); // 100 + '...'
});
});

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
use App\Application\LiveComponents\Dashboard\QueueStatsComponent;
use App\Application\LiveComponents\Dashboard\QueueStatsState;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Queue\Queue;
use App\Framework\Queue\Services\JobMetricsManagerInterface;
use App\Framework\Queue\ValueObjects\JobMetrics;
use App\Framework\Core\ValueObjects\Duration;
describe('QueueStatsComponent', function () {
beforeEach(function () {
// Mock Queue
$this->queue = Mockery::mock(Queue::class);
// Mock JobMetricsManager
$this->metricsManager = Mockery::mock(JobMetricsManagerInterface::class);
$this->componentId = ComponentId::create('queue-stats', 'test');
$this->initialState = QueueStatsState::empty();
});
afterEach(function () {
Mockery::close();
});
it('polls and updates state with queue statistics', function () {
// Mock queue stats
$this->queue->shouldReceive('getStats')
->once()
->andReturn([
'total_size' => 42,
'priority_breakdown' => ['high' => 10, 'medium' => 20, 'low' => 12],
]);
$this->queue->shouldReceive('size')
->once()
->andReturn(42);
// Mock metrics
$jobMetrics = new JobMetrics(
totalJobs: 1000,
successfulJobs: 950,
failedJobs: 50,
avgExecutionTime: Duration::fromMilliseconds(123.45),
successRate: 95.0
);
$this->metricsManager->shouldReceive('getAllQueueMetrics')
->once()
->with('1 hour')
->andReturn([$jobMetrics]);
$component = new QueueStatsComponent(
id: $this->componentId,
state: $this->initialState,
queue: $this->queue,
metricsManager: $this->metricsManager
);
$newState = $component->poll();
expect($newState)->toBeInstanceOf(QueueStatsState::class);
expect($newState->currentQueueSize)->toBe(42);
expect($newState->totalJobs)->toBe(1000);
expect($newState->successfulJobs)->toBe(950);
expect($newState->failedJobs)->toBe(50);
expect($newState->successRate)->toBe(95.0);
expect($newState->avgExecutionTimeMs)->toBe(123.45);
expect($newState->lastUpdated)->not->toBe($this->initialState->lastUpdated);
});
it('has correct poll interval', function () {
$component = new QueueStatsComponent(
id: $this->componentId,
state: $this->initialState,
queue: $this->queue,
metricsManager: $this->metricsManager
);
expect($component->getPollInterval())->toBe(5000);
});
it('returns correct render data', 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'
);
$component = new QueueStatsComponent(
id: $this->componentId,
state: $state,
queue: $this->queue,
metricsManager: $this->metricsManager
);
$renderData = $component->getRenderData();
expect($renderData->templatePath)->toBe('livecomponent-queue-stats');
expect($renderData->data)->toHaveKey('componentId');
expect($renderData->data)->toHaveKey('stateJson');
expect($renderData->data)->toHaveKey('pollInterval');
expect($renderData->data['pollInterval'])->toBe(5000);
expect($renderData->data['currentQueueSize'])->toBe(10);
expect($renderData->data['totalJobs'])->toBe(100);
expect($renderData->data['successRate'])->toBe(90.0);
});
it('handles empty metrics gracefully', function () {
$this->queue->shouldReceive('getStats')
->once()
->andReturn(['total_size' => 0]);
$this->queue->shouldReceive('size')
->once()
->andReturn(0);
$this->metricsManager->shouldReceive('getAllQueueMetrics')
->once()
->with('1 hour')
->andReturn([]);
$component = new QueueStatsComponent(
id: $this->componentId,
state: $this->initialState,
queue: $this->queue,
metricsManager: $this->metricsManager
);
$newState = $component->poll();
expect($newState->currentQueueSize)->toBe(0);
expect($newState->totalJobs)->toBe(0);
expect($newState->successfulJobs)->toBe(0);
expect($newState->failedJobs)->toBe(0);
});
});

View File

@@ -0,0 +1,332 @@
<?php
declare(strict_types=1);
use App\Application\LiveComponents\Dashboard\SchedulerTimelineComponent;
use App\Application\LiveComponents\Dashboard\SchedulerState;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Scheduler\Services\SchedulerService;
use App\Framework\Scheduler\ValueObjects\ScheduledTask;
use App\Framework\Scheduler\Schedules\CronSchedule;
use App\Framework\Scheduler\Schedules\IntervalSchedule;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Core\ValueObjects\Duration;
describe('SchedulerTimelineComponent', function () {
beforeEach(function () {
$this->scheduler = Mockery::mock(SchedulerService::class);
$this->componentId = ComponentId::create('scheduler-timeline', 'test');
$this->initialState = SchedulerState::empty();
});
afterEach(function () {
Mockery::close();
});
it('polls and updates state with scheduler data', function () {
$now = Timestamp::now();
$in30Minutes = $now->add(Duration::fromMinutes(30));
$in2Hours = $now->add(Duration::fromHours(2));
$task1 = new ScheduledTask(
id: 'backup-task',
schedule: CronSchedule::fromExpression('0 2 * * *'),
callback: fn() => true,
nextRun: $in30Minutes
);
$task2 = new ScheduledTask(
id: 'cleanup-task',
schedule: IntervalSchedule::every(Duration::fromHours(1)),
callback: fn() => true,
nextRun: $in2Hours
);
$this->scheduler->shouldReceive('getScheduledTasks')
->once()
->andReturn([$task1, $task2]);
$this->scheduler->shouldReceive('getDueTasks')
->once()
->with(Mockery::type(Timestamp::class))
->andReturn([]);
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $this->initialState,
scheduler: $this->scheduler
);
$newState = $component->poll();
expect($newState)->toBeInstanceOf(SchedulerState::class);
expect($newState->totalScheduledTasks)->toBe(2);
expect($newState->dueTasks)->toBe(0);
expect($newState->upcomingTasks)->toHaveCount(2);
expect($newState->nextExecution)->not->toBeNull();
});
it('identifies due tasks correctly', function () {
$now = Timestamp::now();
$past = $now->sub(Duration::fromMinutes(5));
$dueTask = new ScheduledTask(
id: 'due-task',
schedule: IntervalSchedule::every(Duration::fromMinutes(10)),
callback: fn() => true,
nextRun: $past
);
$this->scheduler->shouldReceive('getScheduledTasks')
->once()
->andReturn([$dueTask]);
$this->scheduler->shouldReceive('getDueTasks')
->once()
->andReturn([$dueTask]);
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $this->initialState,
scheduler: $this->scheduler
);
$newState = $component->poll();
expect($newState->dueTasks)->toBe(1);
expect($newState->upcomingTasks[0]['is_due'])->toBeTrue();
});
it('formats time until execution correctly for less than 1 minute', function () {
$now = Timestamp::now();
$in30Seconds = $now->add(Duration::fromSeconds(30));
$task = new ScheduledTask(
id: 'imminent-task',
schedule: IntervalSchedule::every(Duration::fromMinutes(1)),
callback: fn() => true,
nextRun: $in30Seconds
);
$this->scheduler->shouldReceive('getScheduledTasks')
->once()
->andReturn([$task]);
$this->scheduler->shouldReceive('getDueTasks')
->once()
->andReturn([]);
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $this->initialState,
scheduler: $this->scheduler
);
$newState = $component->poll();
expect($newState->upcomingTasks[0]['next_run_relative'])->toBe('Less than 1 minute');
});
it('formats time until execution correctly for hours and minutes', function () {
$now = Timestamp::now();
$in5Hours30Min = $now->add(Duration::fromMinutes(330)); // 5.5 hours
$task = new ScheduledTask(
id: 'future-task',
schedule: CronSchedule::fromExpression('30 17 * * *'),
callback: fn() => true,
nextRun: $in5Hours30Min
);
$this->scheduler->shouldReceive('getScheduledTasks')
->once()
->andReturn([$task]);
$this->scheduler->shouldReceive('getDueTasks')
->once()
->andReturn([]);
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $this->initialState,
scheduler: $this->scheduler
);
$newState = $component->poll();
$relativeTime = $newState->upcomingTasks[0]['next_run_relative'];
expect($relativeTime)->toContain('5 hours');
expect($relativeTime)->toContain('30 min');
});
it('formats time until execution correctly for days and hours', function () {
$now = Timestamp::now();
$in2Days4Hours = $now->add(Duration::fromHours(52)); // 2 days, 4 hours
$task = new ScheduledTask(
id: 'distant-task',
schedule: CronSchedule::fromExpression('0 0 * * 0'), // Weekly
callback: fn() => true,
nextRun: $in2Days4Hours
);
$this->scheduler->shouldReceive('getScheduledTasks')
->once()
->andReturn([$task]);
$this->scheduler->shouldReceive('getDueTasks')
->once()
->andReturn([]);
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $this->initialState,
scheduler: $this->scheduler
);
$newState = $component->poll();
$relativeTime = $newState->upcomingTasks[0]['next_run_relative'];
expect($relativeTime)->toContain('2 days');
expect($relativeTime)->toContain('4 hours');
});
it('has correct poll interval', function () {
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $this->initialState,
scheduler: $this->scheduler
);
expect($component->getPollInterval())->toBe(30000);
});
it('returns correct render data', function () {
$upcomingTasks = [
[
'id' => 'task-1',
'schedule_type' => 'cron',
'next_run' => '2024-01-15 13:00:00',
'next_run_relative' => '30 min',
'is_due' => false,
],
];
$state = new SchedulerState(
totalScheduledTasks: 5,
dueTasks: 1,
upcomingTasks: $upcomingTasks,
nextExecution: '2024-01-15 13:00:00',
statistics: ['total_executions' => 100],
lastUpdated: '2024-01-15 12:00:00'
);
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $state,
scheduler: $this->scheduler
);
$renderData = $component->getRenderData();
expect($renderData->templatePath)->toBe('livecomponent-scheduler-timeline');
expect($renderData->data)->toHaveKey('componentId');
expect($renderData->data)->toHaveKey('pollInterval');
expect($renderData->data['pollInterval'])->toBe(30000);
expect($renderData->data['totalScheduledTasks'])->toBe(5);
expect($renderData->data['dueTasks'])->toBe(1);
});
it('handles no scheduled tasks', function () {
$this->scheduler->shouldReceive('getScheduledTasks')
->once()
->andReturn([]);
$this->scheduler->shouldReceive('getDueTasks')
->once()
->andReturn([]);
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $this->initialState,
scheduler: $this->scheduler
);
$newState = $component->poll();
expect($newState->totalScheduledTasks)->toBe(0);
expect($newState->dueTasks)->toBe(0);
expect($newState->upcomingTasks)->toBe([]);
expect($newState->nextExecution)->toBeNull();
});
it('limits upcoming tasks to 10 items', function () {
$tasks = [];
$now = Timestamp::now();
for ($i = 0; $i < 20; $i++) {
$tasks[] = new ScheduledTask(
id: "task-{$i}",
schedule: IntervalSchedule::every(Duration::fromMinutes($i + 1)),
callback: fn() => true,
nextRun: $now->add(Duration::fromMinutes($i + 1))
);
}
$this->scheduler->shouldReceive('getScheduledTasks')
->once()
->andReturn($tasks);
$this->scheduler->shouldReceive('getDueTasks')
->once()
->andReturn([]);
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $this->initialState,
scheduler: $this->scheduler
);
$newState = $component->poll();
expect($newState->totalScheduledTasks)->toBe(20);
expect($newState->upcomingTasks)->toHaveCount(10); // Limited to 10
});
it('detects schedule type correctly', function () {
$now = Timestamp::now();
$cronTask = new ScheduledTask(
id: 'cron-task',
schedule: CronSchedule::fromExpression('0 * * * *'),
callback: fn() => true,
nextRun: $now->add(Duration::fromHours(1))
);
$intervalTask = new ScheduledTask(
id: 'interval-task',
schedule: IntervalSchedule::every(Duration::fromMinutes(30)),
callback: fn() => true,
nextRun: $now->add(Duration::fromMinutes(30))
);
$this->scheduler->shouldReceive('getScheduledTasks')
->once()
->andReturn([$cronTask, $intervalTask]);
$this->scheduler->shouldReceive('getDueTasks')
->once()
->andReturn([]);
$component = new SchedulerTimelineComponent(
id: $this->componentId,
state: $this->initialState,
scheduler: $this->scheduler
);
$newState = $component->poll();
expect($newState->upcomingTasks[0]['schedule_type'])->toBe('cron');
expect($newState->upcomingTasks[1]['schedule_type'])->toBe('interval');
});
});

View File

@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
/**
* LiveComponent Security Testing Examples
*
* Demonstrates testing security features:
* - CSRF protection
* - Rate limiting
* - Idempotency key validation
*/
use function Pest\LiveComponents\mountComponent;
use function Pest\LiveComponents\callAction;
describe('LiveComponent Security', function () {
describe('CSRF Protection', function () {
it('requires valid CSRF token for actions', function () {
$component = mountComponent('counter:test', ['count' => 0]);
// Without CSRF token should fail
expect(fn() => callAction($component, 'increment', [
'_csrf_token' => 'invalid_token'
]))->toThrow(\App\Framework\Exception\Security\CsrfTokenMismatchException::class);
});
it('accepts valid CSRF token', function () {
$component = mountComponent('counter:test', ['count' => 0]);
// Get valid CSRF token from session
$session = container()->get(\App\Framework\Http\Session::class);
$csrfToken = $session->getCsrfToken();
$result = callAction($component, 'increment', [
'_csrf_token' => $csrfToken
]);
expect($result['state']['count'])->toBe(1);
});
it('regenerates CSRF token after action', function () {
$component = mountComponent('counter:test', ['count' => 0]);
$session = container()->get(\App\Framework\Http\Session::class);
$oldToken = $session->getCsrfToken();
callAction($component, 'increment', [
'_csrf_token' => $oldToken
]);
$newToken = $session->getCsrfToken();
expect($newToken)->not->toBe($oldToken);
});
});
describe('Rate Limiting', function () {
it('enforces rate limits on actions', function () {
$component = mountComponent('counter:test', ['count' => 0]);
// Execute action multiple times rapidly
for ($i = 0; $i < 10; $i++) {
callAction($component, 'increment');
}
// 11th request should be rate limited
expect(fn() => callAction($component, 'increment'))
->toThrow(\App\Framework\Exception\Http\RateLimitExceededException::class);
});
it('includes retry-after header in rate limit response', function () {
$component = mountComponent('counter:test', ['count' => 0]);
// Exhaust rate limit
for ($i = 0; $i < 10; $i++) {
callAction($component, 'increment');
}
try {
callAction($component, 'increment');
} catch (\App\Framework\Exception\Http\RateLimitExceededException $e) {
expect($e->getRetryAfter())->toBeGreaterThan(0);
}
});
it('resets rate limit after cooldown period', function () {
$component = mountComponent('counter:test', ['count' => 0]);
// Exhaust rate limit
for ($i = 0; $i < 10; $i++) {
callAction($component, 'increment');
}
// Wait for cooldown (simulate with cache clear in tests)
$cache = container()->get(\App\Framework\Cache\Cache::class);
$cache->clear();
// Should work again after cooldown
$result = callAction($component, 'increment');
expect($result['state']['count'])->toBe(11);
});
});
describe('Idempotency Keys', function () {
it('prevents duplicate action execution with same idempotency key', function () {
$component = mountComponent('counter:test', ['count' => 0]);
$idempotencyKey = 'test-key-' . uniqid();
// First execution
$result1 = callAction($component, 'increment', [
'idempotency_key' => $idempotencyKey
]);
expect($result1['state']['count'])->toBe(1);
// Second execution with same key should return cached result
$result2 = callAction($result1, 'increment', [
'idempotency_key' => $idempotencyKey
]);
// Count should still be 1 (not 2) because action was not re-executed
expect($result2['state']['count'])->toBe(1);
});
it('allows different actions with different idempotency keys', function () {
$component = mountComponent('counter:test', ['count' => 0]);
$result1 = callAction($component, 'increment', [
'idempotency_key' => 'key-1'
]);
$result2 = callAction($result1, 'increment', [
'idempotency_key' => 'key-2'
]);
expect($result2['state']['count'])->toBe(2);
});
it('idempotency key expires after TTL', function () {
$component = mountComponent('counter:test', ['count' => 0]);
$idempotencyKey = 'test-key-expiry';
// First execution
callAction($component, 'increment', [
'idempotency_key' => $idempotencyKey
]);
// Simulate TTL expiry by clearing cache
$cache = container()->get(\App\Framework\Cache\Cache::class);
$cache->clear();
// Should execute again after expiry
$result = callAction($component, 'increment', [
'idempotency_key' => $idempotencyKey
]);
expect($result['state']['count'])->toBe(2);
});
it('includes idempotency metadata in response', function () {
$component = mountComponent('counter:test', ['count' => 0]);
$idempotencyKey = 'test-key-metadata';
$result = callAction($component, 'increment', [
'idempotency_key' => $idempotencyKey
]);
// Check for idempotency metadata (if implemented)
// This depends on your specific implementation
expect($result)->toHaveKey('idempotency');
expect($result['idempotency']['key'])->toBe($idempotencyKey);
expect($result['idempotency']['cached'])->toBeFalse();
});
});
describe('Combined Security Features', function () {
it('enforces all security layers together', function () {
$component = mountComponent('counter:test', ['count' => 0]);
$session = container()->get(\App\Framework\Http\Session::class);
$csrfToken = $session->getCsrfToken();
$idempotencyKey = 'combined-test-' . uniqid();
// Valid request with all security features
$result = callAction($component, 'increment', [
'_csrf_token' => $csrfToken,
'idempotency_key' => $idempotencyKey
]);
expect($result['state']['count'])->toBe(1);
// Retry with same idempotency key but new CSRF token
$newCsrfToken = $session->getCsrfToken();
$result2 = callAction($result, 'increment', [
'_csrf_token' => $newCsrfToken,
'idempotency_key' => $idempotencyKey
]);
// Should return cached result due to idempotency
expect($result2['state']['count'])->toBe(1);
});
it('validates security in correct order: CSRF -> Rate Limit -> Idempotency', function () {
$component = mountComponent('counter:test', ['count' => 0]);
// Invalid CSRF should fail before rate limit check
try {
callAction($component, 'increment', [
'_csrf_token' => 'invalid'
]);
expect(false)->toBeTrue('Should have thrown CSRF exception');
} catch (\Exception $e) {
expect($e)->toBeInstanceOf(\App\Framework\Exception\Security\CsrfTokenMismatchException::class);
}
// Rate limit should be checked before idempotency
$session = container()->get(\App\Framework\Http\Session::class);
$csrfToken = $session->getCsrfToken();
// Exhaust rate limit
for ($i = 0; $i < 10; $i++) {
callAction($component, 'increment', [
'_csrf_token' => $session->getCsrfToken()
]);
}
// Even with valid idempotency key, rate limit should trigger first
try {
callAction($component, 'increment', [
'_csrf_token' => $session->getCsrfToken(),
'idempotency_key' => 'test-key'
]);
expect(false)->toBeTrue('Should have thrown rate limit exception');
} catch (\Exception $e) {
expect($e)->toBeInstanceOf(\App\Framework\Exception\Http\RateLimitExceededException::class);
}
});
});
});

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
use App\Application\LiveComponents\Dashboard\WorkerHealthComponent;
use App\Application\LiveComponents\Dashboard\WorkerHealthState;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Queue\Services\WorkerRegistry;
use App\Framework\Queue\Entities\Worker;
use App\Framework\Queue\ValueObjects\WorkerId;
use App\Framework\Core\ValueObjects\Timestamp;
describe('WorkerHealthComponent', function () {
beforeEach(function () {
$this->workerRegistry = Mockery::mock(WorkerRegistry::class);
$this->componentId = ComponentId::create('worker-health', 'test');
$this->initialState = WorkerHealthState::empty();
});
afterEach(function () {
Mockery::close();
});
it('polls and updates state with worker health data', function () {
$now = Timestamp::now();
$oneMinuteAgo = $now->sub(Duration::fromSeconds(60));
$worker1 = new Worker(
id: WorkerId::generate(),
hostname: 'server-01',
processId: 12345,
startedAt: $oneMinuteAgo,
lastHeartbeat: $now,
status: 'active',
currentJobs: 2,
maxJobs: 10,
memoryUsageMb: 128.5,
cpuUsage: 45.2
);
$worker2 = new Worker(
id: WorkerId::generate(),
hostname: 'server-02',
processId: 67890,
startedAt: $oneMinuteAgo,
lastHeartbeat: $now->sub(Duration::fromMinutes(5)), // Unhealthy - old heartbeat
status: 'active',
currentJobs: 0,
maxJobs: 10,
memoryUsageMb: 64.0,
cpuUsage: 98.5 // Unhealthy - high CPU
);
$this->workerRegistry->shouldReceive('findActiveWorkers')
->once()
->andReturn([$worker1, $worker2]);
$component = new WorkerHealthComponent(
id: $this->componentId,
state: $this->initialState,
workerRegistry: $this->workerRegistry
);
$newState = $component->poll();
expect($newState)->toBeInstanceOf(WorkerHealthState::class);
expect($newState->activeWorkers)->toBe(2);
expect($newState->totalWorkers)->toBe(2);
expect($newState->jobsInProgress)->toBe(2); // Only worker1 has jobs
expect($newState->workerDetails)->toHaveCount(2);
// Worker 1 should be healthy
expect($newState->workerDetails[0]['healthy'])->toBeTrue();
expect($newState->workerDetails[0]['hostname'])->toBe('server-01');
// Worker 2 should be unhealthy
expect($newState->workerDetails[1]['healthy'])->toBeFalse();
});
it('identifies healthy workers correctly', function () {
$now = Timestamp::now();
$healthyWorker = new Worker(
id: WorkerId::generate(),
hostname: 'healthy-server',
processId: 11111,
startedAt: $now->sub(Duration::fromMinutes(5)),
lastHeartbeat: $now->sub(Duration::fromSeconds(30)), // Recent heartbeat
status: 'active',
currentJobs: 5,
maxJobs: 10,
memoryUsageMb: 100.0,
cpuUsage: 50.0 // Normal CPU
);
$this->workerRegistry->shouldReceive('findActiveWorkers')
->once()
->andReturn([$healthyWorker]);
$component = new WorkerHealthComponent(
id: $this->componentId,
state: $this->initialState,
workerRegistry: $this->workerRegistry
);
$newState = $component->poll();
expect($newState->workerDetails[0]['healthy'])->toBeTrue();
});
it('identifies unhealthy workers by stale heartbeat', function () {
$now = Timestamp::now();
$staleWorker = new Worker(
id: WorkerId::generate(),
hostname: 'stale-server',
processId: 22222,
startedAt: $now->sub(Duration::fromMinutes(10)),
lastHeartbeat: $now->sub(Duration::fromMinutes(3)), // Stale heartbeat
status: 'active',
currentJobs: 2,
maxJobs: 10,
memoryUsageMb: 80.0,
cpuUsage: 30.0
);
$this->workerRegistry->shouldReceive('findActiveWorkers')
->once()
->andReturn([$staleWorker]);
$component = new WorkerHealthComponent(
id: $this->componentId,
state: $this->initialState,
workerRegistry: $this->workerRegistry
);
$newState = $component->poll();
expect($newState->workerDetails[0]['healthy'])->toBeFalse();
});
it('identifies unhealthy workers by high CPU', function () {
$now = Timestamp::now();
$highCpuWorker = new Worker(
id: WorkerId::generate(),
hostname: 'high-cpu-server',
processId: 33333,
startedAt: $now->sub(Duration::fromMinutes(5)),
lastHeartbeat: $now, // Recent heartbeat
status: 'active',
currentJobs: 8,
maxJobs: 10,
memoryUsageMb: 200.0,
cpuUsage: 96.5 // High CPU
);
$this->workerRegistry->shouldReceive('findActiveWorkers')
->once()
->andReturn([$highCpuWorker]);
$component = new WorkerHealthComponent(
id: $this->componentId,
state: $this->initialState,
workerRegistry: $this->workerRegistry
);
$newState = $component->poll();
expect($newState->workerDetails[0]['healthy'])->toBeFalse();
});
it('has correct poll interval', function () {
$component = new WorkerHealthComponent(
id: $this->componentId,
state: $this->initialState,
workerRegistry: $this->workerRegistry
);
expect($component->getPollInterval())->toBe(5000);
});
it('returns correct render data', function () {
$workerDetails = [
[
'id' => 'worker-1',
'hostname' => 'server-01',
'healthy' => true,
'jobs' => 5,
],
];
$state = new WorkerHealthState(
activeWorkers: 1,
totalWorkers: 1,
jobsInProgress: 5,
workerDetails: $workerDetails,
lastUpdated: '2024-01-15 12:00:00'
);
$component = new WorkerHealthComponent(
id: $this->componentId,
state: $state,
workerRegistry: $this->workerRegistry
);
$renderData = $component->getRenderData();
expect($renderData->templatePath)->toBe('livecomponent-worker-health');
expect($renderData->data)->toHaveKey('componentId');
expect($renderData->data)->toHaveKey('pollInterval');
expect($renderData->data['pollInterval'])->toBe(5000);
expect($renderData->data['activeWorkers'])->toBe(1);
expect($renderData->data['jobsInProgress'])->toBe(5);
});
it('handles no active workers', function () {
$this->workerRegistry->shouldReceive('findActiveWorkers')
->once()
->andReturn([]);
$component = new WorkerHealthComponent(
id: $this->componentId,
state: $this->initialState,
workerRegistry: $this->workerRegistry
);
$newState = $component->poll();
expect($newState->activeWorkers)->toBe(0);
expect($newState->totalWorkers)->toBe(0);
expect($newState->jobsInProgress)->toBe(0);
expect($newState->workerDetails)->toBe([]);
});
});