- 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.
495 lines
20 KiB
PHP
495 lines
20 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Core\ValueObjects\Duration;
|
|
use App\Framework\Queue\ValueObjects\JobMetadata;
|
|
use App\Framework\Queue\ValueObjects\JobPayload;
|
|
use App\Framework\Queue\ValueObjects\QueuePriority;
|
|
use App\Framework\Retry\Strategies\ExponentialBackoffStrategy;
|
|
use App\Framework\Retry\Strategies\FixedDelayStrategy;
|
|
|
|
describe('JobPayload Value Object', function () {
|
|
|
|
beforeEach(function () {
|
|
$this->simpleJob = new class () {
|
|
public function handle(): string
|
|
{
|
|
return 'executed';
|
|
}
|
|
};
|
|
|
|
$this->complexJob = new class () {
|
|
public function __construct(
|
|
public string $id = 'test-123',
|
|
public array $data = ['key' => 'value']
|
|
) {
|
|
}
|
|
|
|
public function process(): array
|
|
{
|
|
return $this->data;
|
|
}
|
|
};
|
|
});
|
|
|
|
describe('Basic Construction', function () {
|
|
it('can be created with minimal parameters', function () {
|
|
$payload = JobPayload::create($this->simpleJob);
|
|
|
|
expect($payload->job)->toBe($this->simpleJob);
|
|
expect($payload->priority->isNormal())->toBeTrue();
|
|
expect($payload->delay->toSeconds())->toBe(0);
|
|
expect($payload->timeout)->toBeNull();
|
|
expect($payload->retryStrategy)->toBeNull();
|
|
expect($payload->metadata)->not->toBeNull();
|
|
});
|
|
|
|
it('accepts all configuration parameters', function () {
|
|
$priority = QueuePriority::high();
|
|
$delay = Duration::fromMinutes(5);
|
|
$timeout = Duration::fromSeconds(30);
|
|
$retryStrategy = new ExponentialBackoffStrategy(maxAttempts: 3);
|
|
$metadata = JobMetadata::create(['user_id' => 123]);
|
|
|
|
$payload = JobPayload::create(
|
|
$this->complexJob,
|
|
$priority,
|
|
$delay,
|
|
$timeout,
|
|
$retryStrategy,
|
|
$metadata
|
|
);
|
|
|
|
expect($payload->job)->toBe($this->complexJob);
|
|
expect($payload->priority)->toBe($priority);
|
|
expect($payload->delay)->toBe($delay);
|
|
expect($payload->timeout)->toBe($timeout);
|
|
expect($payload->retryStrategy)->toBe($retryStrategy);
|
|
expect($payload->metadata)->toBe($metadata);
|
|
});
|
|
|
|
it('is immutable - readonly properties cannot be changed', function () {
|
|
$payload = JobPayload::create($this->simpleJob);
|
|
|
|
// This would cause a PHP error if attempted:
|
|
// $payload->job = new stdClass(); // Fatal error: Cannot modify readonly property
|
|
// $payload->priority = QueuePriority::high(); // Fatal error: Cannot modify readonly property
|
|
|
|
// Test that the properties are indeed readonly
|
|
$reflection = new ReflectionClass($payload);
|
|
foreach (['job', 'priority', 'delay', 'timeout', 'retryStrategy', 'metadata'] as $prop) {
|
|
$property = $reflection->getProperty($prop);
|
|
expect($property->isReadOnly())->toBeTrue("Property {$prop} should be readonly");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Factory Methods', function () {
|
|
it('creates immediate jobs with high priority and no delay', function () {
|
|
$payload = JobPayload::immediate($this->simpleJob);
|
|
|
|
expect($payload->priority->isHigh())->toBeTrue();
|
|
expect($payload->delay->toSeconds())->toBe(0);
|
|
expect($payload->isReady())->toBeTrue();
|
|
});
|
|
|
|
it('creates delayed jobs with specified delay', function () {
|
|
$delay = Duration::fromMinutes(15);
|
|
$payload = JobPayload::delayed($this->simpleJob, $delay);
|
|
|
|
expect($payload->delay)->toBe($delay);
|
|
expect($payload->isDelayed())->toBeTrue();
|
|
expect($payload->isReady())->toBeFalse();
|
|
});
|
|
|
|
it('creates critical jobs with critical priority and short timeout', function () {
|
|
$payload = JobPayload::critical($this->simpleJob);
|
|
|
|
expect($payload->priority->isCritical())->toBeTrue();
|
|
expect($payload->delay->toSeconds())->toBe(0);
|
|
expect($payload->timeout->toSeconds())->toBe(30);
|
|
expect($payload->hasTimeout())->toBeTrue();
|
|
});
|
|
|
|
it('creates background jobs with low priority and retry strategy', function () {
|
|
$payload = JobPayload::background($this->simpleJob);
|
|
|
|
expect($payload->priority->isLow())->toBeTrue();
|
|
expect($payload->timeout->toMinutes())->toBe(30);
|
|
expect($payload->hasRetryStrategy())->toBeTrue();
|
|
expect($payload->retryStrategy->getMaxAttempts())->toBe(5);
|
|
});
|
|
});
|
|
|
|
describe('Immutable Transformations', function () {
|
|
beforeEach(function () {
|
|
$this->originalPayload = JobPayload::create(
|
|
$this->simpleJob,
|
|
QueuePriority::normal(),
|
|
Duration::zero()
|
|
);
|
|
});
|
|
|
|
it('withPriority() creates new instance with different priority', function () {
|
|
$newPriority = QueuePriority::high();
|
|
$newPayload = $this->originalPayload->withPriority($newPriority);
|
|
|
|
expect($newPayload)->not->toBe($this->originalPayload);
|
|
expect($newPayload->priority)->toBe($newPriority);
|
|
expect($newPayload->job)->toBe($this->originalPayload->job);
|
|
expect($newPayload->delay)->toBe($this->originalPayload->delay);
|
|
|
|
// Original should be unchanged
|
|
expect($this->originalPayload->priority->isNormal())->toBeTrue();
|
|
});
|
|
|
|
it('withDelay() creates new instance with different delay', function () {
|
|
$newDelay = Duration::fromMinutes(10);
|
|
$newPayload = $this->originalPayload->withDelay($newDelay);
|
|
|
|
expect($newPayload)->not->toBe($this->originalPayload);
|
|
expect($newPayload->delay)->toBe($newDelay);
|
|
expect($newPayload->job)->toBe($this->originalPayload->job);
|
|
expect($newPayload->priority)->toBe($this->originalPayload->priority);
|
|
|
|
// Original should be unchanged
|
|
expect($this->originalPayload->delay->toSeconds())->toBe(0);
|
|
});
|
|
|
|
it('withTimeout() creates new instance with timeout', function () {
|
|
$timeout = Duration::fromSeconds(45);
|
|
$newPayload = $this->originalPayload->withTimeout($timeout);
|
|
|
|
expect($newPayload)->not->toBe($this->originalPayload);
|
|
expect($newPayload->timeout)->toBe($timeout);
|
|
expect($newPayload->hasTimeout())->toBeTrue();
|
|
|
|
// Original should be unchanged
|
|
expect($this->originalPayload->timeout)->toBeNull();
|
|
});
|
|
|
|
it('withRetryStrategy() creates new instance with retry strategy', function () {
|
|
$retryStrategy = new FixedDelayStrategy(Duration::fromSeconds(30), 3);
|
|
$newPayload = $this->originalPayload->withRetryStrategy($retryStrategy);
|
|
|
|
expect($newPayload)->not->toBe($this->originalPayload);
|
|
expect($newPayload->retryStrategy)->toBe($retryStrategy);
|
|
expect($newPayload->hasRetryStrategy())->toBeTrue();
|
|
|
|
// Original should be unchanged
|
|
expect($this->originalPayload->retryStrategy)->toBeNull();
|
|
});
|
|
|
|
it('withMetadata() creates new instance with metadata', function () {
|
|
$metadata = JobMetadata::create(['source' => 'test', 'version' => '1.0']);
|
|
$newPayload = $this->originalPayload->withMetadata($metadata);
|
|
|
|
expect($newPayload)->not->toBe($this->originalPayload);
|
|
expect($newPayload->metadata)->toBe($metadata);
|
|
|
|
// Original should be unchanged
|
|
expect($this->originalPayload->metadata)->not->toBe($metadata);
|
|
});
|
|
|
|
it('can chain multiple transformations', function () {
|
|
$finalPayload = $this->originalPayload
|
|
->withPriority(QueuePriority::critical())
|
|
->withDelay(Duration::fromSeconds(30))
|
|
->withTimeout(Duration::fromMinutes(5))
|
|
->withRetryStrategy(new ExponentialBackoffStrategy(maxAttempts: 3));
|
|
|
|
expect($finalPayload->priority->isCritical())->toBeTrue();
|
|
expect($finalPayload->delay->toSeconds())->toBe(30);
|
|
expect($finalPayload->timeout->toMinutes())->toBe(5);
|
|
expect($finalPayload->hasRetryStrategy())->toBeTrue();
|
|
|
|
// Original should be completely unchanged
|
|
expect($this->originalPayload->priority->isNormal())->toBeTrue();
|
|
expect($this->originalPayload->delay->toSeconds())->toBe(0);
|
|
expect($this->originalPayload->timeout)->toBeNull();
|
|
expect($this->originalPayload->retryStrategy)->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Status Checking Methods', function () {
|
|
it('isReady() returns true for jobs with no delay', function () {
|
|
$payload = JobPayload::create($this->simpleJob, delay: Duration::zero());
|
|
expect($payload->isReady())->toBeTrue();
|
|
|
|
$immediate = JobPayload::immediate($this->simpleJob);
|
|
expect($immediate->isReady())->toBeTrue();
|
|
});
|
|
|
|
it('isReady() returns false for delayed jobs', function () {
|
|
$payload = JobPayload::delayed($this->simpleJob, Duration::fromSeconds(30));
|
|
expect($payload->isReady())->toBeFalse();
|
|
});
|
|
|
|
it('isDelayed() returns true for jobs with delay', function () {
|
|
$payload = JobPayload::delayed($this->simpleJob, Duration::fromMinutes(1));
|
|
expect($payload->isDelayed())->toBeTrue();
|
|
});
|
|
|
|
it('isDelayed() returns false for immediate jobs', function () {
|
|
$payload = JobPayload::immediate($this->simpleJob);
|
|
expect($payload->isDelayed())->toBeFalse();
|
|
});
|
|
|
|
it('hasRetryStrategy() reflects retry strategy presence', function () {
|
|
$withoutRetry = JobPayload::create($this->simpleJob);
|
|
expect($withoutRetry->hasRetryStrategy())->toBeFalse();
|
|
|
|
$withRetry = JobPayload::background($this->simpleJob);
|
|
expect($withRetry->hasRetryStrategy())->toBeTrue();
|
|
});
|
|
|
|
it('hasTimeout() reflects timeout presence', function () {
|
|
$withoutTimeout = JobPayload::create($this->simpleJob);
|
|
expect($withoutTimeout->hasTimeout())->toBeFalse();
|
|
|
|
$withTimeout = JobPayload::critical($this->simpleJob);
|
|
expect($withTimeout->hasTimeout())->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('Time Calculations', function () {
|
|
it('getAvailableTime() returns current time for immediate jobs', function () {
|
|
$payload = JobPayload::immediate($this->simpleJob);
|
|
$available = $payload->getAvailableTime();
|
|
$now = time();
|
|
|
|
expect($available)->toBeGreaterThanOrEqual($now - 1);
|
|
expect($available)->toBeLessThanOrEqual($now + 1);
|
|
});
|
|
|
|
it('getAvailableTime() returns future time for delayed jobs', function () {
|
|
$delay = Duration::fromSeconds(300); // 5 minutes
|
|
$payload = JobPayload::delayed($this->simpleJob, $delay);
|
|
|
|
$available = $payload->getAvailableTime();
|
|
$expected = time() + 300;
|
|
|
|
expect($available)->toBeGreaterThanOrEqual($expected - 1);
|
|
expect($available)->toBeLessThanOrEqual($expected + 1);
|
|
});
|
|
});
|
|
|
|
describe('Serialization and Array Conversion', function () {
|
|
it('can serialize job objects', function () {
|
|
$payload = JobPayload::create($this->complexJob);
|
|
$serialized = $payload->serialize();
|
|
|
|
expect($serialized)->toBeString();
|
|
expect(strlen($serialized))->toBeGreaterThan(0);
|
|
|
|
// Should be able to unserialize
|
|
$unserialized = unserialize($serialized);
|
|
expect($unserialized)->toBeInstanceOf(get_class($this->complexJob));
|
|
expect($unserialized->id)->toBe('test-123');
|
|
});
|
|
|
|
it('getJobClass() returns correct class name', function () {
|
|
$payload = JobPayload::create($this->complexJob);
|
|
$className = $payload->getJobClass();
|
|
|
|
expect($className)->toBeString();
|
|
expect($className)->toContain('class@anonymous');
|
|
});
|
|
|
|
it('toArray() provides comprehensive job information', function () {
|
|
$retryStrategy = new ExponentialBackoffStrategy(maxAttempts: 5);
|
|
$payload = JobPayload::create(
|
|
$this->complexJob,
|
|
QueuePriority::high(),
|
|
Duration::fromSeconds(120),
|
|
Duration::fromMinutes(10),
|
|
$retryStrategy,
|
|
JobMetadata::create(['source' => 'api'])
|
|
);
|
|
|
|
$array = $payload->toArray();
|
|
|
|
expect($array)->toHaveKey('job_class');
|
|
expect($array)->toHaveKey('priority');
|
|
expect($array)->toHaveKey('priority_value');
|
|
expect($array)->toHaveKey('delay_seconds');
|
|
expect($array)->toHaveKey('timeout_seconds');
|
|
expect($array)->toHaveKey('has_retry_strategy');
|
|
expect($array)->toHaveKey('max_attempts');
|
|
expect($array)->toHaveKey('available_at');
|
|
expect($array)->toHaveKey('metadata');
|
|
|
|
expect($array['priority'])->toBe('high');
|
|
expect($array['priority_value'])->toBe(100);
|
|
expect($array['delay_seconds'])->toBe(120);
|
|
expect($array['timeout_seconds'])->toBe(600);
|
|
expect($array['has_retry_strategy'])->toBeTrue();
|
|
expect($array['max_attempts'])->toBe(5);
|
|
expect($array['available_at'])->toBeInt();
|
|
expect($array['metadata'])->toBeArray();
|
|
});
|
|
|
|
it('toArray() handles null values correctly', function () {
|
|
$payload = JobPayload::create($this->simpleJob);
|
|
$array = $payload->toArray();
|
|
|
|
expect($array['timeout_seconds'])->toBeNull();
|
|
expect($array['has_retry_strategy'])->toBeFalse();
|
|
expect($array['max_attempts'])->toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases and Error Handling', function () {
|
|
it('maintains object reference integrity', function () {
|
|
$job = new stdClass();
|
|
$job->property = 'value';
|
|
|
|
$payload = JobPayload::create($job);
|
|
|
|
// Same object reference should be maintained
|
|
expect($payload->job)->toBe($job);
|
|
expect($payload->job->property)->toBe('value');
|
|
|
|
// Modifying original object should affect payload job (reference)
|
|
$job->property = 'modified';
|
|
expect($payload->job->property)->toBe('modified');
|
|
});
|
|
|
|
it('handles complex job objects with dependencies', function () {
|
|
$complexJob = new class () {
|
|
public array $config;
|
|
|
|
public \DateTime $created;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->config = ['timeout' => 30, 'retries' => 3];
|
|
$this->created = new \DateTime();
|
|
}
|
|
|
|
public function getData(): array
|
|
{
|
|
return [
|
|
'config' => $this->config,
|
|
'created' => $this->created->format('Y-m-d H:i:s'),
|
|
];
|
|
}
|
|
};
|
|
|
|
$payload = JobPayload::create($complexJob);
|
|
expect($payload->job->getData())->toBeArray();
|
|
expect($payload->job->config['timeout'])->toBe(30);
|
|
});
|
|
|
|
it('preserves metadata across transformations', function () {
|
|
$originalMetadata = JobMetadata::create(['initial' => 'data']);
|
|
$payload = JobPayload::create($this->simpleJob, metadata: $originalMetadata);
|
|
|
|
// Transform without changing metadata
|
|
$newPayload = $payload->withPriority(QueuePriority::critical());
|
|
|
|
expect($newPayload->metadata)->toBe($originalMetadata);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('JobPayload Integration Scenarios', function () {
|
|
|
|
beforeEach(function () {
|
|
$this->emailJob = new class () {
|
|
public function __construct(
|
|
public string $to = 'test@example.com',
|
|
public string $subject = 'Test Email',
|
|
public string $body = 'Hello World'
|
|
) {
|
|
}
|
|
|
|
public function send(): bool
|
|
{
|
|
// Simulate email sending
|
|
return true;
|
|
}
|
|
};
|
|
|
|
$this->reportJob = new class () {
|
|
public function __construct(
|
|
public array $criteria = ['period' => 'monthly'],
|
|
public string $format = 'pdf'
|
|
) {
|
|
}
|
|
|
|
public function generate(): string
|
|
{
|
|
return "Report generated with format: {$this->format}";
|
|
}
|
|
};
|
|
});
|
|
|
|
it('handles email job scenarios', function () {
|
|
// Immediate notification
|
|
$urgent = JobPayload::immediate($this->emailJob);
|
|
expect($urgent->priority->isHigh())->toBeTrue();
|
|
expect($urgent->isReady())->toBeTrue();
|
|
|
|
// Delayed newsletter
|
|
$newsletter = JobPayload::delayed($this->emailJob, Duration::fromHours(2));
|
|
expect($newsletter->isDelayed())->toBeTrue();
|
|
|
|
// Critical alert with timeout
|
|
$alert = JobPayload::critical($this->emailJob);
|
|
expect($alert->priority->isCritical())->toBeTrue();
|
|
expect($alert->hasTimeout())->toBeTrue();
|
|
});
|
|
|
|
it('handles report generation scenarios', function () {
|
|
// Background monthly report
|
|
$monthlyReport = JobPayload::background($this->reportJob);
|
|
expect($monthlyReport->priority->isLow())->toBeTrue();
|
|
expect($monthlyReport->hasRetryStrategy())->toBeTrue();
|
|
|
|
// Add custom metadata for tracking
|
|
$metadata = JobMetadata::create([
|
|
'user_id' => 123,
|
|
'report_type' => 'financial',
|
|
'department' => 'accounting',
|
|
]);
|
|
|
|
$customReport = $monthlyReport->withMetadata($metadata);
|
|
expect($customReport->metadata->get('user_id'))->toBe(123);
|
|
expect($customReport->metadata->get('report_type'))->toBe('financial');
|
|
});
|
|
|
|
it('handles job priority escalation scenarios', function () {
|
|
// Start as normal priority
|
|
$job = JobPayload::create($this->emailJob, QueuePriority::normal());
|
|
expect($job->priority->isNormal())->toBeTrue();
|
|
|
|
// Escalate to high priority (customer complaint)
|
|
$escalated = $job->withPriority(QueuePriority::high());
|
|
expect($escalated->priority->isHigh())->toBeTrue();
|
|
|
|
// Further escalate to critical (system outage notification)
|
|
$critical = $escalated->withPriority(QueuePriority::critical());
|
|
expect($critical->priority->isCritical())->toBeTrue();
|
|
|
|
// Original should remain unchanged
|
|
expect($job->priority->isNormal())->toBeTrue();
|
|
});
|
|
|
|
it('demonstrates retry strategy configuration', function () {
|
|
// Simple retry for transient failures
|
|
$simpleRetry = new FixedDelayStrategy(Duration::fromSeconds(30), 3);
|
|
$payload = JobPayload::create($this->emailJob)->withRetryStrategy($simpleRetry);
|
|
|
|
expect($payload->retryStrategy->getMaxAttempts())->toBe(3);
|
|
|
|
// Exponential backoff for rate limiting
|
|
$exponentialRetry = new ExponentialBackoffStrategy(maxAttempts: 5);
|
|
$rateLimitedPayload = $payload->withRetryStrategy($exponentialRetry);
|
|
|
|
expect($rateLimitedPayload->retryStrategy->getMaxAttempts())->toBe(5);
|
|
expect($rateLimitedPayload->retryStrategy)->toBeInstanceOf(ExponentialBackoffStrategy::class);
|
|
});
|
|
});
|