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,129 @@
<?php
declare(strict_types=1);
use App\Framework\HttpClient\Curl\Handle;
use App\Framework\HttpClient\Curl\HandleOption;
use App\Framework\HttpClient\Curl\Pause;
use App\Framework\HttpClient\Curl\Exception\HandleException;
describe('Curl\HandleException', function () {
it('provides context for initialization failures', function () {
try {
// Simulate initialization failure (hard to test directly)
throw HandleException::initializationFailed('https://example.com');
} catch (HandleException $e) {
expect($e->getMessage())->toContain('Failed to initialize cURL handle');
expect($e->getData())->toHaveKey('url');
expect($e->getData()['url'])->toBe('https://example.com');
}
});
it('provides context for execution failures', function () {
$exception = HandleException::executionFailed('Connection timeout', 28);
expect($exception->getMessage())->toContain('cURL execution failed');
expect($exception->getData())->toHaveKey('curl_error_code');
expect($exception->getData())->toHaveKey('curl_error_message');
expect($exception->getData()['curl_error_code'])->toBe(28);
expect($exception->getData()['curl_error_message'])->toBe('Connection timeout');
});
it('provides context for invalid output target', function () {
$exception = HandleException::invalidOutputTarget('not-a-resource');
expect($exception->getMessage())->toContain('Invalid output target');
expect($exception->getData())->toHaveKey('output_type');
expect($exception->getData()['output_type'])->toBe('string');
});
it('provides context for setOption failures', function () {
$exception = HandleException::setOptionFailed(
HandleOption::Timeout,
'invalid',
'Invalid type'
);
expect($exception->getMessage())->toContain('Failed to set cURL option');
expect($exception->getData())->toHaveKey('option');
expect($exception->getData())->toHaveKey('value');
expect($exception->getData())->toHaveKey('error');
expect($exception->getData()['option'])->toBe('Timeout');
});
it('provides context for invalid option types', function () {
$exception = HandleException::invalidOptionType(HandleOption::Timeout, 'invalid');
expect($exception->getMessage())->toContain('Invalid type for option Timeout');
expect($exception->getMessage())->toContain('expected integer');
expect($exception->getMessage())->toContain('got string');
expect($exception->getData())->toHaveKey('option');
expect($exception->getData())->toHaveKey('expected_type');
expect($exception->getData())->toHaveKey('actual_type');
expect($exception->getData()['expected_type'])->toBe('integer');
expect($exception->getData()['actual_type'])->toBe('string');
});
it('provides context for pause failures', function () {
$exception = HandleException::pauseFailed(Pause::Recv, 'Pause operation failed');
expect($exception->getMessage())->toContain('Failed to pause cURL handle');
expect($exception->getData())->toHaveKey('pause_flag');
expect($exception->getData())->toHaveKey('pause_value');
expect($exception->getData()['pause_flag'])->toBe('Recv');
});
it('provides context for escape failures', function () {
$exception = HandleException::escapeFailed('some string');
expect($exception->getMessage())->toContain('Failed to URL-escape string');
expect($exception->getData())->toHaveKey('string_length');
expect($exception->getData()['string_length'])->toBe(strlen('some string'));
});
it('provides context for unescape failures', function () {
$exception = HandleException::unescapeFailed('some%20string');
expect($exception->getMessage())->toContain('Failed to URL-unescape string');
expect($exception->getData())->toHaveKey('string');
expect($exception->getData()['string'])->toBe('some%20string');
});
it('provides context for upkeep failures', function () {
$exception = HandleException::upkeepFailed('Connection check failed');
expect($exception->getMessage())->toContain('cURL upkeep failed');
expect($exception->getData())->toHaveKey('error');
expect($exception->getData()['error'])->toBe('Connection check failed');
});
it('includes error details in real execution failure', function () {
$handle = new Handle('https://invalid-domain-that-does-not-exist-99999.com');
$handle->setOption(HandleOption::Timeout, 3);
try {
$handle->fetch();
throw new Exception('Should have thrown HandleException');
} catch (HandleException $e) {
expect($e->getMessage())->toContain('cURL execution failed');
expect($e->getData())->toHaveKey('curl_error_code');
expect($e->getData())->toHaveKey('curl_error_message');
expect($e->getData()['curl_error_code'])->toBeGreaterThan(0);
expect($e->getData()['curl_error_message'])->not->toBeEmpty();
}
});
it('includes error details in type validation failure', function () {
$handle = new Handle();
try {
$handle->setOption(HandleOption::Timeout, 'not-an-integer');
throw new Exception('Should have thrown HandleException');
} catch (HandleException $e) {
expect($e->getMessage())->toContain('Invalid type');
expect($e->getData()['option'])->toBe('Timeout');
expect($e->getData()['expected_type'])->toBe('integer');
expect($e->getData()['actual_type'])->toBe('string');
}
});
});

View File

@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
use App\Framework\HttpClient\Curl\Handle;
use App\Framework\HttpClient\Curl\HandleOption;
use App\Framework\HttpClient\Curl\Info;
use App\Framework\HttpClient\Curl\Pause;
use App\Framework\HttpClient\Curl\Exception\HandleException;
describe('Curl\Handle', function () {
it('can be initialized without URL', function () {
$handle = new Handle();
expect($handle)->toBeInstanceOf(Handle::class);
expect($handle->errorNumber)->toBe(0);
expect($handle->errorMessage)->toBe('');
});
it('can be initialized with URL', function () {
$handle = new Handle('https://httpbin.org/get');
expect($handle)->toBeInstanceOf(Handle::class);
});
it('can fetch from URL', function () {
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$response = $handle->fetch();
expect($response)->toBeString();
expect($response)->toContain('"url"');
expect($handle->getInfo(Info::ResponseCode))->toBe(200);
});
it('supports fluent API with method chaining', function () {
$handle = new Handle();
$result = $handle
->setOption(HandleOption::Url, 'https://httpbin.org/get')
->setOption(HandleOption::Timeout, 10);
expect($result)->toBe($handle); // Same instance
expect($handle->fetch())->toContain('"url"');
});
it('can set multiple options at once', function () {
$handle = new Handle();
$handle->setOptions([
HandleOption::Url->value => 'https://httpbin.org/get',
HandleOption::Timeout->value => 10,
HandleOption::UserAgent->value => 'Test-Agent/1.0',
]);
$response = $handle->fetch();
expect($response)->toContain('Test-Agent/1.0');
});
it('validates option types - integer expected', function () {
$handle = new Handle();
expect(fn() => $handle->setOption(HandleOption::Timeout, 'invalid'))
->toThrow(HandleException::class, 'Invalid type for option Timeout: expected integer, got string');
});
it('validates option types - boolean expected', function () {
$handle = new Handle();
expect(fn() => $handle->setOption(HandleOption::FollowLocation, 'yes'))
->toThrow(HandleException::class, 'Invalid type for option FollowLocation: expected boolean, got string');
});
it('validates option types - string expected', function () {
$handle = new Handle();
expect(fn() => $handle->setOption(HandleOption::UserAgent, 123))
->toThrow(HandleException::class, 'Invalid type for option UserAgent: expected string, got int');
});
it('validates option types - array expected', function () {
$handle = new Handle();
expect(fn() => $handle->setOption(HandleOption::HttpHeader, 'not-an-array'))
->toThrow(HandleException::class, 'Invalid type for option HttpHeader: expected array, got string');
});
it('can get single info value', function () {
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$handle->fetch();
expect($handle->getInfo(Info::ResponseCode))->toBe(200);
expect($handle->getInfo(Info::EffectiveUrl))->toBe('https://httpbin.org/get');
expect($handle->getInfo(Info::TotalTime))->toBeGreaterThan(0);
});
it('can get all info at once', function () {
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$handle->fetch();
$allInfo = $handle->getInfo();
expect($allInfo)->toBeArray();
expect($allInfo)->toHaveKey('url');
expect($allInfo)->toHaveKey('http_code');
expect($allInfo)->toHaveKey('total_time');
});
it('throws on invalid URL', function () {
$handle = new Handle('https://invalid-domain-that-does-not-exist-99999.com');
$handle->setOption(HandleOption::Timeout, 5);
$handle->fetch();
})->throws(HandleException::class);
it('updates error properties on failure', function () {
$handle = new Handle('https://invalid-domain-that-does-not-exist-99999.com');
$handle->setOption(HandleOption::Timeout, 5);
try {
$handle->fetch();
} catch (HandleException $e) {
expect($handle->errorNumber)->toBeGreaterThan(0);
expect($handle->errorMessage)->not->toBeEmpty();
}
});
it('can reset options', function () {
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::UserAgent, 'Test-Agent/1.0');
$handle->reset();
// After reset, default options apply
$handle->setOption(HandleOption::Url, 'https://httpbin.org/user-agent');
$response = $handle->fetch();
// Should not contain custom user agent after reset
expect($response)->not->toContain('Test-Agent/1.0');
});
it('can URL-escape strings', function () {
$handle = new Handle();
$escaped = $handle->escapeUrl('hello world');
expect($escaped)->toBe('hello%20world');
});
it('can URL-unescape strings', function () {
$handle = new Handle();
$unescaped = $handle->unescapeUrl('hello%20world');
expect($unescaped)->toBe('hello world');
});
it('can execute with resource output', function () {
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$tempFile = tmpfile();
$handle->execute($tempFile);
fseek($tempFile, 0);
$content = stream_get_contents($tempFile);
fclose($tempFile);
expect($content)->toContain('"url"');
});
it('can execute with callable output', function () {
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$receivedData = '';
$callback = function ($ch, $data) use (&$receivedData) {
$receivedData .= $data;
return strlen($data);
};
$handle->execute($callback);
expect($receivedData)->toContain('"url"');
});
it('throws on invalid output target', function () {
$handle = new Handle('https://httpbin.org/get');
expect(fn() => $handle->execute('invalid'))
->toThrow(HandleException::class, 'Invalid output target');
});
it('can perform POST request', function () {
$handle = new Handle('https://httpbin.org/post');
$data = ['name' => 'Test', 'email' => 'test@example.com'];
$handle->setOptions([
HandleOption::Post->value => true,
HandleOption::PostFields->value => json_encode($data),
HandleOption::HttpHeader->value => ['Content-Type: application/json'],
HandleOption::Timeout->value => 10,
]);
$response = $handle->fetch();
expect($response)->toContain('"name": "Test"');
expect($response)->toContain('"email": "test@example.com"');
});
it('can follow redirects', function () {
$handle = new Handle('https://httpbin.org/redirect/2');
$handle->setOptions([
HandleOption::FollowLocation->value => true,
HandleOption::MaxRedirs->value => 5,
HandleOption::Timeout->value => 10,
]);
$response = $handle->fetch();
expect($handle->getInfo(Info::RedirectCount))->toBe(2);
expect($handle->getInfo(Info::ResponseCode))->toBe(200);
});
it('respects timeout settings', function () {
$handle = new Handle('https://httpbin.org/delay/10');
$handle->setOption(HandleOption::Timeout, 2);
expect(fn() => $handle->fetch())
->toThrow(HandleException::class);
});
it('can set custom headers', function () {
$handle = new Handle('https://httpbin.org/headers');
$handle->setOptions([
HandleOption::HttpHeader->value => [
'X-Custom-Header: test-value',
'X-Another-Header: another-value',
],
HandleOption::Timeout->value => 10,
]);
$response = $handle->fetch();
expect($response)->toContain('X-Custom-Header');
expect($response)->toContain('test-value');
});
it('provides access to underlying resource', function () {
$handle = new Handle();
$resource = $handle->getResource();
expect($resource)->toBeInstanceOf(CurlHandle::class);
});
it('error properties use asymmetric visibility', function () {
$handle = new Handle('https://invalid-domain.com');
// Can read
expect($handle->errorNumber)->toBe(0);
expect($handle->errorMessage)->toBe('');
// Cannot write (would be compile error, so we just verify they're public readable)
expect(property_exists($handle, 'errorNumber'))->toBeTrue();
expect(property_exists($handle, 'errorMessage'))->toBeTrue();
});
it('can handle SSL verification', function () {
$handle = new Handle('https://www.google.com');
$handle->setOptions([
HandleOption::SslVerifyPeer->value => true,
HandleOption::SslVerifyHost->value => 2,
HandleOption::Timeout->value => 10,
]);
$response = $handle->fetch();
expect($handle->getInfo(Info::ResponseCode))->toBe(200);
expect($handle->getInfo(Info::SslVerifyResult))->toBe(0); // 0 = success
});
it('can disable SSL verification for testing', function () {
$handle = new Handle('https://self-signed.badssl.com/');
$handle->setOptions([
HandleOption::SslVerifyPeer->value => false,
HandleOption::SslVerifyHost->value => 0,
HandleOption::Timeout->value => 10,
]);
// Should not throw SSL error
$response = $handle->fetch();
expect($response)->toBeString();
});
});
describe('Curl\HandleOption enum', function () {
it('has getName() method', function () {
expect(HandleOption::Timeout->getName())->toBe('Timeout');
expect(HandleOption::UserAgent->getName())->toBe('UserAgent');
});
it('correctly identifies boolean options', function () {
expect(HandleOption::FollowLocation->expectsBoolean())->toBeTrue();
expect(HandleOption::SslVerifyPeer->expectsBoolean())->toBeTrue();
expect(HandleOption::Timeout->expectsBoolean())->toBeFalse();
});
it('correctly identifies integer options', function () {
expect(HandleOption::Timeout->expectsInteger())->toBeTrue();
expect(HandleOption::MaxRedirs->expectsInteger())->toBeTrue();
expect(HandleOption::UserAgent->expectsInteger())->toBeFalse();
});
it('correctly identifies string options', function () {
expect(HandleOption::UserAgent->expectsString())->toBeTrue();
expect(HandleOption::Url->expectsString())->toBeTrue();
expect(HandleOption::Timeout->expectsString())->toBeFalse();
});
it('correctly identifies array options', function () {
expect(HandleOption::HttpHeader->expectsArray())->toBeTrue();
expect(HandleOption::Quote->expectsArray())->toBeTrue();
expect(HandleOption::UserAgent->expectsArray())->toBeFalse();
});
it('correctly identifies callable options', function () {
expect(HandleOption::WriteFunction->expectsCallable())->toBeTrue();
expect(HandleOption::HeaderFunction->expectsCallable())->toBeTrue();
expect(HandleOption::Timeout->expectsCallable())->toBeFalse();
});
it('correctly identifies resource options', function () {
expect(HandleOption::File->expectsResource())->toBeTrue();
expect(HandleOption::InFile->expectsResource())->toBeTrue();
expect(HandleOption::UserAgent->expectsResource())->toBeFalse();
});
});
describe('Curl\Info enum', function () {
it('has all common info cases', function () {
expect(Info::ResponseCode->value)->toBe(CURLINFO_RESPONSE_CODE);
// Note: CURLINFO_HTTP_CODE is an alias for CURLINFO_RESPONSE_CODE
expect(Info::TotalTime->value)->toBe(CURLINFO_TOTAL_TIME);
expect(Info::EffectiveUrl->value)->toBe(CURLINFO_EFFECTIVE_URL);
});
});
describe('Curl\Pause enum', function () {
it('has all pause cases', function () {
expect(Pause::Recv->value)->toBe(CURLPAUSE_RECV);
expect(Pause::Send->value)->toBe(CURLPAUSE_SEND);
expect(Pause::All->value)->toBe(CURLPAUSE_ALL);
expect(Pause::Cont->value)->toBe(CURLPAUSE_CONT);
});
});

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
use App\Framework\HttpClient\Curl\Handle;
use App\Framework\HttpClient\Curl\HandleOption;
use App\Framework\HttpClient\Curl\Info;
use App\Framework\HttpClient\Curl\MultiHandlePool;
describe('Curl\MultiHandlePool', function () {
it('can be initialized with default concurrency', function () {
$pool = new MultiHandlePool();
expect($pool)->toBeInstanceOf(MultiHandlePool::class);
expect($pool->getMaxConcurrent())->toBe(10);
expect($pool->queueSize())->toBe(0);
expect($pool->completedCount())->toBe(0);
});
it('can be initialized with custom concurrency limit', function () {
$pool = new MultiHandlePool(maxConcurrent: 3);
expect($pool->getMaxConcurrent())->toBe(3);
});
it('throws on invalid concurrency limit', function () {
new MultiHandlePool(maxConcurrent: 0);
})->throws(\InvalidArgumentException::class, 'Max concurrent must be at least 1');
it('can add handles to queue', function () {
$pool = new MultiHandlePool();
$pool->add(new Handle('https://httpbin.org/get'));
$pool->add(new Handle('https://httpbin.org/uuid'));
expect($pool->queueSize())->toBe(2);
expect($pool->totalCount())->toBe(2);
});
it('supports method chaining', function () {
$pool = new MultiHandlePool();
$result = $pool
->add(new Handle('https://httpbin.org/get'))
->add(new Handle('https://httpbin.org/uuid'));
expect($result)->toBe($pool);
expect($pool->queueSize())->toBe(2);
});
it('executes all handles respecting concurrency limit', function () {
$pool = new MultiHandlePool(maxConcurrent: 2);
// Add 5 handles
for ($i = 0; $i < 5; $i++) {
$pool->add(new Handle('https://httpbin.org/get'));
}
expect($pool->queueSize())->toBe(5);
$completed = $pool->executeAll();
expect($completed)->toHaveCount(5);
expect($pool->completedCount())->toBe(5);
expect($pool->queueSize())->toBe(0);
expect($pool->isComplete())->toBeTrue();
});
it('executes requests in batches', function () {
$pool = new MultiHandlePool(maxConcurrent: 2);
$urls = [
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
'https://httpbin.org/delay/1',
];
foreach ($urls as $url) {
$pool->add(new Handle($url));
}
$startTime = microtime(true);
$completed = $pool->executeAll();
$duration = microtime(true) - $startTime;
expect($completed)->toHaveCount(4);
// With concurrency 2, should take ~2 seconds (2 batches of 2)
// If sequential, would take ~4 seconds
expect($duration)->toBeLessThan(3.5);
expect($duration)->toBeGreaterThan(1.5);
});
it('tracks progress during execution', function () {
$pool = new MultiHandlePool(maxConcurrent: 10);
for ($i = 0; $i < 10; $i++) {
$pool->add(new Handle('https://httpbin.org/get'));
}
expect($pool->getProgress())->toBe(0.0);
$pool->executeAll();
expect($pool->getProgress())->toBe(100.0);
expect($pool->isComplete())->toBeTrue();
});
it('can clear all handles', function () {
$pool = new MultiHandlePool();
$pool->add(new Handle('https://httpbin.org/get'));
$pool->add(new Handle('https://httpbin.org/uuid'));
expect($pool->queueSize())->toBe(2);
$pool->clear();
expect($pool->queueSize())->toBe(0);
expect($pool->completedCount())->toBe(0);
expect($pool->totalCount())->toBe(0);
});
it('handles empty pool gracefully', function () {
$pool = new MultiHandlePool();
$completed = $pool->executeAll();
expect($completed)->toBeArray();
expect($completed)->toHaveCount(0);
expect($pool->isComplete())->toBeTrue();
});
it('can execute single handle', function () {
$pool = new MultiHandlePool(maxConcurrent: 5);
$pool->add(new Handle('https://httpbin.org/get'));
$completed = $pool->executeAll();
expect($completed)->toHaveCount(1);
});
it('handles many requests efficiently', function () {
$pool = new MultiHandlePool(maxConcurrent: 10);
// Add 30 handles
for ($i = 0; $i < 30; $i++) {
$pool->add(new Handle('https://httpbin.org/get'));
}
expect($pool->totalCount())->toBe(30);
$startTime = microtime(true);
$completed = $pool->executeAll();
$duration = microtime(true) - $startTime;
expect($completed)->toHaveCount(30);
expect($pool->completedCount())->toBe(30);
// With concurrency 10, should execute in ~3 batches
// Much faster than sequential (would be 30+ seconds)
expect($duration)->toBeLessThan(15.0);
});
it('preserves handle state after execution', function () {
$pool = new MultiHandlePool();
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$pool->add($handle);
$pool->executeAll();
// Handle should still be accessible and have response info
expect($handle->getInfo(Info::ResponseCode))->toBe(200);
expect($handle->getInfo(Info::TotalTime))->toBeGreaterThan(0);
});
it('can reuse pool after execution', function () {
$pool = new MultiHandlePool(maxConcurrent: 5);
// First batch
$pool->add(new Handle('https://httpbin.org/get'));
$pool->add(new Handle('https://httpbin.org/uuid'));
$firstBatch = $pool->executeAll();
expect($firstBatch)->toHaveCount(2);
// Clear and add new batch
$pool->clear();
$pool->add(new Handle('https://httpbin.org/get'));
$pool->add(new Handle('https://httpbin.org/uuid'));
$pool->add(new Handle('https://httpbin.org/get'));
$secondBatch = $pool->executeAll();
expect($secondBatch)->toHaveCount(3);
});
it('respects strict concurrency limit', function () {
$pool = new MultiHandlePool(maxConcurrent: 1);
// Add multiple handles
for ($i = 0; $i < 3; $i++) {
$pool->add(new Handle('https://httpbin.org/delay/1'));
}
$startTime = microtime(true);
$pool->executeAll();
$duration = microtime(true) - $startTime;
// With concurrency 1, should execute sequentially (~3 seconds)
expect($duration)->toBeGreaterThan(2.5);
});
it('provides accurate count methods', function () {
$pool = new MultiHandlePool(maxConcurrent: 2);
// Add 5 handles
for ($i = 0; $i < 5; $i++) {
$pool->add(new Handle('https://httpbin.org/get'));
}
expect($pool->queueSize())->toBe(5);
expect($pool->activeCount())->toBe(0);
expect($pool->completedCount())->toBe(0);
expect($pool->totalCount())->toBe(5);
// After execution
$pool->executeAll();
expect($pool->queueSize())->toBe(0);
expect($pool->activeCount())->toBe(0);
expect($pool->completedCount())->toBe(5);
expect($pool->totalCount())->toBe(5);
});
});
describe('Curl\MultiHandlePool edge cases', function () {
it('handles mix of fast and slow requests', function () {
$pool = new MultiHandlePool(maxConcurrent: 3);
// Mix of instant and delayed requests
$pool->add(new Handle('https://httpbin.org/get'));
$pool->add(new Handle('https://httpbin.org/delay/1'));
$pool->add(new Handle('https://httpbin.org/get'));
$pool->add(new Handle('https://httpbin.org/delay/1'));
$completed = $pool->executeAll();
expect($completed)->toHaveCount(4);
});
it('handles large concurrency limit', function () {
$pool = new MultiHandlePool(maxConcurrent: 100);
for ($i = 0; $i < 20; $i++) {
$pool->add(new Handle('https://httpbin.org/get'));
}
$completed = $pool->executeAll();
expect($completed)->toHaveCount(20);
});
});

View File

@@ -0,0 +1,496 @@
<?php
declare(strict_types=1);
use App\Framework\HttpClient\Curl\Handle;
use App\Framework\HttpClient\Curl\HandleOption;
use App\Framework\HttpClient\Curl\Info;
use App\Framework\HttpClient\Curl\MultiHandle;
use App\Framework\HttpClient\Curl\Exception\MultiHandleException;
describe('Curl\MultiHandle', function () {
it('can be initialized empty', function () {
$multi = new MultiHandle();
expect($multi)->toBeInstanceOf(MultiHandle::class);
expect($multi->count())->toBe(0);
});
it('can be initialized with handles', function () {
$handle1 = new Handle('https://httpbin.org/get');
$handle2 = new Handle('https://httpbin.org/uuid');
$multi = new MultiHandle($handle1, $handle2);
expect($multi)->toBeInstanceOf(MultiHandle::class);
expect($multi->count())->toBe(2);
expect($multi->getHandles())->toHaveCount(2);
});
it('can add handles', function () {
$multi = new MultiHandle();
$handle = new Handle('https://httpbin.org/get');
$result = $multi->addHandle($handle);
expect($result)->toBe($multi); // Fluent API
expect($multi->count())->toBe(1);
});
it('can remove handles', function () {
$multi = new MultiHandle();
$handle = new Handle('https://httpbin.org/get');
$multi->addHandle($handle);
expect($multi->count())->toBe(1);
$multi->removeHandle($handle);
expect($multi->count())->toBe(0);
});
it('can get all attached handles', function () {
$multi = new MultiHandle();
$handle1 = new Handle('https://httpbin.org/get');
$handle2 = new Handle('https://httpbin.org/uuid');
$multi->addHandle($handle1);
$multi->addHandle($handle2);
$handles = $multi->getHandles();
expect($handles)->toHaveCount(2);
expect($handles)->toContain($handle1);
expect($handles)->toContain($handle2);
});
it('can execute parallel requests with executeAll()', function () {
$multi = new MultiHandle();
$urls = [
'https://httpbin.org/delay/1',
'https://httpbin.org/uuid',
'https://httpbin.org/get',
];
$handles = [];
foreach ($urls as $url) {
$handle = new Handle($url);
$handle->setOption(HandleOption::Timeout, 10);
$handles[] = $handle;
$multi->addHandle($handle);
}
expect($multi->count())->toBe(3);
$startTime = microtime(true);
$completed = $multi->executeAll();
$duration = microtime(true) - $startTime;
// Parallel execution should be faster than sequential (3+ seconds)
expect($duration)->toBeLessThan(2.5);
expect($completed)->toHaveCount(3);
// Verify all requests completed successfully
foreach ($handles as $handle) {
expect($handle->getInfo(Info::ResponseCode))->toBe(200);
}
});
it('executes requests in parallel not sequentially', function () {
$multi = new MultiHandle();
// Create 3 requests with 1 second delay each
$handles = [];
for ($i = 0; $i < 3; $i++) {
$handle = new Handle('https://httpbin.org/delay/1');
$handle->setOption(HandleOption::Timeout, 10);
$handles[] = $handle;
$multi->addHandle($handle);
}
$startTime = microtime(true);
$multi->executeAll();
$duration = microtime(true) - $startTime;
// If sequential: ~3 seconds
// If parallel: ~1 second
expect($duration)->toBeLessThan(2.0);
expect($duration)->toBeGreaterThan(0.8);
});
it('can handle mixed success and failure', function () {
$multi = new MultiHandle();
$handle1 = new Handle('https://httpbin.org/get');
$handle1->setOption(HandleOption::Timeout, 10);
$handle2 = new Handle('https://httpbin.org/status/404');
$handle2->setOption(HandleOption::Timeout, 10);
$handle3 = new Handle('https://httpbin.org/status/500');
$handle3->setOption(HandleOption::Timeout, 10);
$multi->addHandle($handle1);
$multi->addHandle($handle2);
$multi->addHandle($handle3);
$completed = $multi->executeAll();
expect($completed)->toHaveCount(3);
// Check individual status codes
expect($handle1->getInfo(Info::ResponseCode))->toBe(200);
expect($handle2->getInfo(Info::ResponseCode))->toBe(404);
expect($handle3->getInfo(Info::ResponseCode))->toBe(500);
});
it('can execute with manual control loop', function () {
$multi = new MultiHandle();
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$multi->addHandle($handle);
$stillRunning = 0;
$iterations = 0;
do {
$multi->execute($stillRunning);
if ($stillRunning > 0) {
$multi->select();
}
$iterations++;
} while ($stillRunning > 0 && $iterations < 100);
expect($stillRunning)->toBe(0);
expect($handle->getInfo(Info::ResponseCode))->toBe(200);
});
it('can handle many parallel requests', function () {
$multi = new MultiHandle();
$numRequests = 10;
$handles = [];
for ($i = 0; $i < $numRequests; $i++) {
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$handles[] = $handle;
$multi->addHandle($handle);
}
expect($multi->count())->toBe($numRequests);
$startTime = microtime(true);
$completed = $multi->executeAll();
$duration = microtime(true) - $startTime;
expect($completed)->toHaveCount($numRequests);
// Should complete much faster than sequential
expect($duration)->toBeLessThan(5.0);
// Verify all completed
foreach ($handles as $handle) {
expect($handle->getInfo(Info::ResponseCode))->toBe(200);
}
});
it('provides access to underlying resource', function () {
$multi = new MultiHandle();
$resource = $multi->getResource();
expect($resource)->toBeInstanceOf(CurlMultiHandle::class);
});
it('can set multi handle options', function () {
$multi = new MultiHandle();
// CURLMOPT_PIPELINING enable HTTP/1.1 pipelining
if (defined('CURLMOPT_PIPELINING')) {
$result = $multi->setOption(CURLMOPT_PIPELINING, 1);
expect($result)->toBe($multi); // Fluent API
}
expect($multi)->toBeInstanceOf(MultiHandle::class);
});
it('handles empty multi handle gracefully', function () {
$multi = new MultiHandle();
// Execute without any handles
$completed = $multi->executeAll();
expect($completed)->toHaveCount(0);
expect($multi->count())->toBe(0);
});
it('can add and remove handles multiple times', function () {
$multi = new MultiHandle();
$handle1 = new Handle('https://httpbin.org/get');
$handle2 = new Handle('https://httpbin.org/uuid');
$multi->addHandle($handle1);
expect($multi->count())->toBe(1);
$multi->addHandle($handle2);
expect($multi->count())->toBe(2);
$multi->removeHandle($handle1);
expect($multi->count())->toBe(1);
$multi->removeHandle($handle2);
expect($multi->count())->toBe(0);
});
it('select returns number of handles with activity', function () {
$multi = new MultiHandle();
$handle = new Handle('https://httpbin.org/delay/1');
$handle->setOption(HandleOption::Timeout, 10);
$multi->addHandle($handle);
$stillRunning = 0;
$multi->execute($stillRunning);
if ($stillRunning > 0) {
$result = $multi->select(1.0);
// Result can be -1 (error), 0 (timeout), or >0 (activity)
expect($result)->toBeGreaterThanOrEqual(-1);
}
expect($stillRunning)->toBeGreaterThanOrEqual(0);
});
it('getInfo returns transfer information', function () {
$multi = new MultiHandle();
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$multi->addHandle($handle);
$stillRunning = 0;
$messagesInQueue = 0;
do {
$multi->execute($stillRunning);
if ($stillRunning > 0) {
$multi->select();
}
// Check for completed transfers
while ($info = $multi->getInfo($messagesInQueue)) {
expect($info)->toBeArray();
expect($info)->toHaveKey('handle');
expect($info)->toHaveKey('result');
}
} while ($stillRunning > 0);
expect($stillRunning)->toBe(0);
});
it('can handle POST requests in parallel', function () {
$multi = new MultiHandle();
$handles = [];
$data = ['name' => 'Test', 'timestamp' => time()];
for ($i = 0; $i < 3; $i++) {
$handle = new Handle('https://httpbin.org/post');
$handle->setOptions([
HandleOption::Post->value => true,
HandleOption::PostFields->value => json_encode($data),
HandleOption::HttpHeader->value => ['Content-Type: application/json'],
HandleOption::Timeout->value => 10,
]);
$handles[] = $handle;
$multi->addHandle($handle);
}
$completed = $multi->executeAll();
expect($completed)->toHaveCount(3);
foreach ($handles as $handle) {
expect($handle->getInfo(Info::ResponseCode))->toBe(200);
}
});
it('automatically cleans up on destruction', function () {
$multi = new MultiHandle();
$handle1 = new Handle('https://httpbin.org/get');
$handle2 = new Handle('https://httpbin.org/uuid');
$multi->addHandle($handle1);
$multi->addHandle($handle2);
expect($multi->count())->toBe(2);
unset($multi);
// If we reach here without errors, cleanup worked
expect(true)->toBeTrue();
});
it('can register onComplete callback', function () {
$multi = new MultiHandle();
$completedUrls = [];
$multi->onComplete(function(Handle $handle) use (&$completedUrls) {
$completedUrls[] = $handle->getInfo(Info::EffectiveUrl);
});
$handle1 = new Handle('https://httpbin.org/get');
$handle2 = new Handle('https://httpbin.org/uuid');
$handle1->setOption(HandleOption::Timeout, 10);
$handle2->setOption(HandleOption::Timeout, 10);
$multi->addHandle($handle1);
$multi->addHandle($handle2);
$multi->executeAll();
expect($completedUrls)->toHaveCount(2);
expect($completedUrls[0])->toContain('httpbin.org');
expect($completedUrls[1])->toContain('httpbin.org');
});
it('can register onError callback', function () {
$multi = new MultiHandle();
$errors = [];
$multi->onError(function(Handle $handle, int $errorCode) use (&$errors) {
$errors[] = [
'url' => $handle->getInfo(Info::EffectiveUrl),
'code' => $errorCode
];
});
// Create a handle that will timeout
$handle = new Handle('https://httpbin.org/delay/10');
$handle->setOption(HandleOption::Timeout, 1); // 1 second timeout
$multi->addHandle($handle);
$multi->executeAll();
expect($errors)->toHaveCount(1);
expect($errors[0]['code'])->toBe(CURLE_OPERATION_TIMEDOUT);
});
it('can register onProgress callback', function () {
$multi = new MultiHandle();
$progressUpdates = [];
$multi->onProgress(function(int $completed, int $total) use (&$progressUpdates) {
$progressUpdates[] = ['completed' => $completed, 'total' => $total];
});
$handle1 = new Handle('https://httpbin.org/get');
$handle2 = new Handle('https://httpbin.org/uuid');
$handle3 = new Handle('https://httpbin.org/delay/1');
$handle1->setOption(HandleOption::Timeout, 10);
$handle2->setOption(HandleOption::Timeout, 10);
$handle3->setOption(HandleOption::Timeout, 10);
$multi->addHandle($handle1);
$multi->addHandle($handle2);
$multi->addHandle($handle3);
$multi->executeAll();
// Should have progress updates
expect($progressUpdates)->not->toBeEmpty();
expect($progressUpdates)->toHaveCount(3);
// Final update should show all completed
$finalUpdate = end($progressUpdates);
expect($finalUpdate['completed'])->toBe(3);
expect($finalUpdate['total'])->toBe(3);
});
it('can chain callback registrations', function () {
$multi = new MultiHandle();
$result = $multi
->onComplete(fn($handle) => null)
->onError(fn($handle, $code) => null)
->onProgress(fn($completed, $total) => null);
expect($result)->toBe($multi);
});
it('triggers callbacks in correct order', function () {
$multi = new MultiHandle();
$events = [];
$multi->onComplete(function(Handle $handle) use (&$events) {
$events[] = 'complete';
});
$multi->onProgress(function(int $completed, int $total) use (&$events) {
$events[] = 'progress';
});
$handle = new Handle('https://httpbin.org/get');
$handle->setOption(HandleOption::Timeout, 10);
$multi->addHandle($handle);
$multi->executeAll();
expect($events)->toContain('complete');
expect($events)->toContain('progress');
});
});
describe('Curl\MultiHandle edge cases', function () {
it('handles timeout in parallel requests', function () {
$multi = new MultiHandle();
$handle1 = new Handle('https://httpbin.org/delay/1');
$handle1->setOption(HandleOption::Timeout, 10);
$handle2 = new Handle('https://httpbin.org/delay/20');
$handle2->setOption(HandleOption::Timeout, 2);
$multi->addHandle($handle1);
$multi->addHandle($handle2);
$completed = $multi->executeAll();
// Handle1 should succeed
expect($handle1->getInfo(Info::ResponseCode))->toBe(200);
// Handle2 should timeout (response code 0 or error)
$handle2Code = $handle2->getInfo(Info::ResponseCode);
expect($handle2Code)->toBeIn([0, 28]); // 0 = timeout, 28 = operation timeout
});
it('can reuse multi handle for multiple batches', function () {
$multi = new MultiHandle();
// First batch
$handle1 = new Handle('https://httpbin.org/get');
$handle1->setOption(HandleOption::Timeout, 10);
$multi->addHandle($handle1);
$multi->executeAll();
expect($handle1->getInfo(Info::ResponseCode))->toBe(200);
$multi->removeHandle($handle1);
// Second batch
$handle2 = new Handle('https://httpbin.org/uuid');
$handle2->setOption(HandleOption::Timeout, 10);
$multi->addHandle($handle2);
$multi->executeAll();
expect($handle2->getInfo(Info::ResponseCode))->toBe(200);
});
});