The MethodSignatureAnalyzer was rejecting command methods with void return type, causing the schedule:run command to fail validation.
525 lines
19 KiB
PHP
525 lines
19 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Integration;
|
|
|
|
use App\Framework\Cache\Cache;
|
|
use App\Framework\Cache\CacheIdentifier;
|
|
use App\Framework\Cache\CacheItem;
|
|
use App\Framework\Cache\CacheKey;
|
|
use App\Framework\Cache\CacheResult;
|
|
use App\Framework\Cache\Driver\InMemoryCache;
|
|
use App\Framework\Core\PathProvider;
|
|
use App\Framework\Core\ValueObjects\Duration;
|
|
use App\Framework\DI\DefaultContainer;
|
|
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
|
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
|
use App\Framework\ExceptionHandling\ErrorHandlingConfig;
|
|
use App\Framework\ExceptionHandling\ErrorKernel;
|
|
use App\Framework\ExceptionHandling\ErrorRendererFactory;
|
|
use App\Framework\ExceptionHandling\Renderers\ResponseErrorRenderer;
|
|
use App\Framework\Http\Status;
|
|
use App\Framework\Performance\PerformanceCategory;
|
|
use App\Framework\Performance\Contracts\PerformanceCollectorInterface;
|
|
use App\Framework\Performance\PerformanceConfig;
|
|
use App\Framework\Performance\PerformanceMetric;
|
|
use App\Framework\Performance\PerformanceService;
|
|
use App\Framework\Serialization\Serializer;
|
|
use App\Framework\View\Engine;
|
|
use App\Framework\Context\ExecutionContext;
|
|
use App\Framework\View\Loading\TemplateLoader;
|
|
use App\Framework\View\TemplateProcessor;
|
|
use Mockery;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* Null performance collector for testing (no-op implementation)
|
|
*/
|
|
class TestPerformanceCollector implements PerformanceCollectorInterface
|
|
{
|
|
public function startTiming(string $key, PerformanceCategory $category, array $context = []): void {}
|
|
public function endTiming(string $key): void {}
|
|
public function measure(string $key, PerformanceCategory $category, callable $callback, array $context = []): mixed
|
|
{
|
|
return $callback();
|
|
}
|
|
public function recordMetric(string $key, PerformanceCategory $category, float $value, array $context = []): void {}
|
|
public function increment(string $key, PerformanceCategory $category, int $amount = 1, array $context = []): void {}
|
|
public function getMetrics(?PerformanceCategory $category = null): array { return []; }
|
|
public function getMetric(string $key): ?PerformanceMetric { return null; }
|
|
public function getTotalRequestTime(): float { return 0.0; }
|
|
public function getTotalRequestMemory(): int { return 0; }
|
|
public function getPeakMemory(): int { return 0; }
|
|
public function reset(): void {}
|
|
public function isEnabled(): bool { return false; }
|
|
public function setEnabled(bool $enabled): void {}
|
|
}
|
|
|
|
/**
|
|
* Simple cache wrapper for testing - adapts InMemoryCache to Cache interface
|
|
*/
|
|
class SimpleCacheWrapper implements Cache
|
|
{
|
|
public function __construct(private InMemoryCache $driver) {}
|
|
|
|
public function get(CacheIdentifier ...$identifiers): CacheResult
|
|
{
|
|
$keys = array_filter($identifiers, fn($id) => $id instanceof CacheKey);
|
|
return $this->driver->get(...$keys);
|
|
}
|
|
|
|
public function set(CacheItem ...$items): bool
|
|
{
|
|
return $this->driver->set(...$items);
|
|
}
|
|
|
|
public function has(CacheIdentifier ...$identifiers): array
|
|
{
|
|
$keys = array_filter($identifiers, fn($id) => $id instanceof CacheKey);
|
|
return $this->driver->has(...$keys);
|
|
}
|
|
|
|
public function forget(CacheIdentifier ...$identifiers): bool
|
|
{
|
|
$keys = array_filter($identifiers, fn($id) => $id instanceof CacheKey);
|
|
return $this->driver->forget(...$keys);
|
|
}
|
|
|
|
public function clear(): bool
|
|
{
|
|
return $this->driver->clear();
|
|
}
|
|
|
|
public function remember(CacheKey $key, callable $callback, ?Duration $ttl = null): CacheItem
|
|
{
|
|
$result = $this->driver->get($key);
|
|
$item = $result->getItem($key);
|
|
if ($item->isHit) {
|
|
return $item;
|
|
}
|
|
$value = $callback();
|
|
$newItem = CacheItem::forSet($key, $value, $ttl);
|
|
$this->driver->set($newItem);
|
|
return CacheItem::hit($key, $value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper functions to create test dependencies
|
|
* Following the dependency chain: TemplateLoader → Engine → ErrorRendererFactory → ErrorKernel
|
|
*/
|
|
|
|
function createTestEngine(): Engine
|
|
{
|
|
$projectRoot = dirname(__DIR__, 2);
|
|
$pathProvider = new PathProvider($projectRoot);
|
|
$cache = new SimpleCacheWrapper(new InMemoryCache());
|
|
|
|
$templateLoader = new TemplateLoader(
|
|
pathProvider: $pathProvider,
|
|
cache: $cache,
|
|
discoveryRegistry: null,
|
|
templates: [],
|
|
templatePath: '/src/Framework/ExceptionHandling/Templates',
|
|
useMtimeInvalidation: false,
|
|
cacheEnabled: false,
|
|
);
|
|
|
|
$performanceCollector = new TestPerformanceCollector();
|
|
$performanceConfig = new PerformanceConfig(enabled: false);
|
|
$performanceService = new PerformanceService(
|
|
collector: $performanceCollector,
|
|
config: $performanceConfig,
|
|
);
|
|
|
|
$container = new DefaultContainer();
|
|
$templateProcessor = new TemplateProcessor(
|
|
astTransformers: [],
|
|
stringProcessors: [],
|
|
container: $container,
|
|
);
|
|
|
|
return new Engine(
|
|
loader: $templateLoader,
|
|
performanceService: $performanceService,
|
|
processor: $templateProcessor,
|
|
cache: $cache,
|
|
cacheEnabled: false,
|
|
);
|
|
}
|
|
|
|
function createTestErrorRendererFactory(?bool $isDebugMode = null): ErrorRendererFactory
|
|
{
|
|
$executionContext = ExecutionContext::forWeb();
|
|
$engine = createTestEngine();
|
|
$config = $isDebugMode !== null
|
|
? new ErrorHandlingConfig(isDebugMode: $isDebugMode)
|
|
: null;
|
|
|
|
return new ErrorRendererFactory(
|
|
executionContext: $executionContext,
|
|
engine: $engine,
|
|
consoleOutput: null,
|
|
config: $config,
|
|
);
|
|
}
|
|
|
|
function createTestErrorKernel(): ErrorKernel
|
|
{
|
|
$rendererFactory = createTestErrorRendererFactory();
|
|
|
|
return new ErrorKernel(
|
|
rendererFactory: $rendererFactory,
|
|
reporter: null,
|
|
);
|
|
}
|
|
|
|
function createTestResponseErrorRenderer(bool $isDebugMode = false): ResponseErrorRenderer
|
|
{
|
|
$engine = createTestEngine();
|
|
|
|
return new ResponseErrorRenderer(
|
|
engine: $engine,
|
|
isDebugMode: $isDebugMode,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Integration tests for unified ExceptionHandling module
|
|
*
|
|
* Tests Strategy 5: ErrorKernel.createHttpResponse()
|
|
* Tests Strategy 6: ErrorBoundary integration (via context enrichment)
|
|
* Tests Strategy 8: Core ExceptionHandling components
|
|
*/
|
|
|
|
describe('ErrorKernel HTTP Response Generation', function () {
|
|
beforeEach(function () {
|
|
// Mock $_SERVER for API detection
|
|
$_SERVER['HTTP_ACCEPT'] = 'application/json';
|
|
$_SERVER['REQUEST_URI'] = '/api/test';
|
|
});
|
|
|
|
afterEach(function () {
|
|
// Cleanup
|
|
unset($_SERVER['HTTP_ACCEPT'], $_SERVER['REQUEST_URI']);
|
|
});
|
|
|
|
it('creates JSON API error response without context', function () {
|
|
$errorKernel = createTestErrorKernel();
|
|
$exception = new RuntimeException('Test API error', 500);
|
|
|
|
$response = $errorKernel->createHttpResponse($exception, null, isDebugMode: false);
|
|
|
|
expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR);
|
|
expect($response->headers->getFirst('Content-Type'))->toBe('application/json');
|
|
|
|
$body = json_decode($response->body, true);
|
|
expect($body['error']['message'])->toBe('An error occurred while processing your request.');
|
|
expect($body['error']['type'])->toBe('ServerError');
|
|
expect($body['error']['code'])->toBe(500);
|
|
});
|
|
|
|
it('creates JSON API error response with debug mode', function () {
|
|
$errorKernel = createTestErrorKernel();
|
|
$exception = new RuntimeException('Database connection failed', 500);
|
|
|
|
$response = $errorKernel->createHttpResponse($exception, null, isDebugMode: true);
|
|
|
|
expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR);
|
|
|
|
$body = json_decode($response->body, true);
|
|
expect($body['error']['message'])->toBe('Database connection failed');
|
|
expect($body['error']['type'])->toBe(RuntimeException::class);
|
|
expect($body['error'])->toHaveKey('file');
|
|
expect($body['error'])->toHaveKey('line');
|
|
expect($body['error'])->toHaveKey('trace');
|
|
});
|
|
|
|
it('creates JSON API error response with WeakMap context', function () {
|
|
$errorKernel = createTestErrorKernel();
|
|
$contextProvider = new ExceptionContextProvider();
|
|
$exception = new RuntimeException('User operation failed', 500);
|
|
|
|
// Enrich exception with context
|
|
$contextData = new ExceptionContextData(
|
|
operation: 'user.create',
|
|
component: 'UserService',
|
|
requestId: 'req-12345',
|
|
occurredAt: new \DateTimeImmutable(),
|
|
metadata: ['user_email' => 'test@example.com']
|
|
);
|
|
$contextProvider->attach($exception, $contextData);
|
|
|
|
$response = $errorKernel->createHttpResponse($exception, $contextProvider, isDebugMode: true);
|
|
|
|
$body = json_decode($response->body, true);
|
|
expect($body['context']['operation'])->toBe('user.create');
|
|
expect($body['context']['component'])->toBe('UserService');
|
|
expect($body['context']['request_id'])->toBe('req-12345');
|
|
expect($body['context'])->toHaveKey('occurred_at');
|
|
});
|
|
});
|
|
|
|
describe('ResponseErrorRenderer', function () {
|
|
beforeEach(function () {
|
|
// Mock $_SERVER for API detection
|
|
$_SERVER['HTTP_ACCEPT'] = 'application/json';
|
|
$_SERVER['REQUEST_URI'] = '/api/test';
|
|
});
|
|
|
|
afterEach(function () {
|
|
// Cleanup
|
|
unset($_SERVER['HTTP_ACCEPT'], $_SERVER['REQUEST_URI']);
|
|
});
|
|
|
|
it('detects API requests correctly', function () {
|
|
$renderer = createTestResponseErrorRenderer(isDebugMode: false);
|
|
$exception = new RuntimeException('Test error');
|
|
|
|
$response = $renderer->render($exception, null);
|
|
|
|
expect($response->headers->getFirst('Content-Type'))->toBe('application/json');
|
|
});
|
|
|
|
it('creates HTML response for non-API requests', function () {
|
|
// Override to non-API request
|
|
$_SERVER['HTTP_ACCEPT'] = 'text/html';
|
|
$_SERVER['REQUEST_URI'] = '/web/page';
|
|
|
|
$renderer = createTestResponseErrorRenderer(isDebugMode: false);
|
|
$exception = new RuntimeException('Page error');
|
|
|
|
$response = $renderer->render($exception, null);
|
|
|
|
expect($response->headers->getFirst('Content-Type'))->toBe('text/html; charset=utf-8');
|
|
expect($response->body)->toContain('<!DOCTYPE html>');
|
|
expect($response->body)->toContain('An error occurred while processing your request.');
|
|
});
|
|
|
|
it('includes debug info in HTML response when enabled', function () {
|
|
$_SERVER['HTTP_ACCEPT'] = 'text/html';
|
|
$_SERVER['REQUEST_URI'] = '/web/page';
|
|
|
|
$renderer = createTestResponseErrorRenderer(isDebugMode: true);
|
|
$contextProvider = new ExceptionContextProvider();
|
|
$exception = new RuntimeException('Debug test error');
|
|
|
|
$contextData = new ExceptionContextData(
|
|
operation: 'page.render',
|
|
component: 'PageController',
|
|
requestId: 'req-67890',
|
|
occurredAt: new \DateTimeImmutable()
|
|
);
|
|
$contextProvider->attach($exception, $contextData);
|
|
|
|
$response = $renderer->render($exception, $contextProvider);
|
|
|
|
expect($response->body)->toContain('Debug Information');
|
|
expect($response->body)->toContain('page.render');
|
|
expect($response->body)->toContain('PageController');
|
|
expect($response->body)->toContain('req-67890');
|
|
});
|
|
|
|
it('maps exception types to HTTP status codes correctly', function () {
|
|
$renderer = createTestResponseErrorRenderer();
|
|
|
|
// InvalidArgumentException → 400
|
|
$exception = new \InvalidArgumentException('Invalid input');
|
|
$response = $renderer->render($exception, null);
|
|
expect($response->status)->toBe(Status::BAD_REQUEST);
|
|
|
|
// RuntimeException → 500
|
|
$exception = new RuntimeException('Runtime error');
|
|
$response = $renderer->render($exception, null);
|
|
expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR);
|
|
|
|
// Custom code in valid range
|
|
$exception = new RuntimeException('Not found', 404);
|
|
$response = $renderer->render($exception, null);
|
|
expect($response->status)->toBe(Status::NOT_FOUND);
|
|
});
|
|
});
|
|
|
|
describe('ExceptionContextProvider WeakMap functionality', function () {
|
|
it('stores and retrieves exception context', function () {
|
|
$contextProvider = new ExceptionContextProvider();
|
|
$exception = new RuntimeException('Test exception');
|
|
|
|
$contextData = new ExceptionContextData(
|
|
operation: 'test.operation',
|
|
component: 'TestComponent',
|
|
requestId: 'test-123',
|
|
occurredAt: new \DateTimeImmutable()
|
|
);
|
|
|
|
$contextProvider->attach($exception, $contextData);
|
|
$retrieved = $contextProvider->get($exception);
|
|
|
|
expect($retrieved)->not->toBeNull();
|
|
expect($retrieved->operation)->toBe('test.operation');
|
|
expect($retrieved->component)->toBe('TestComponent');
|
|
expect($retrieved->requestId)->toBe('test-123');
|
|
});
|
|
|
|
it('returns null for exceptions without context', function () {
|
|
$contextProvider = new ExceptionContextProvider();
|
|
$exception = new RuntimeException('No context');
|
|
|
|
$retrieved = $contextProvider->get($exception);
|
|
|
|
expect($retrieved)->toBeNull();
|
|
});
|
|
|
|
it('uses WeakMap semantics - context garbage collected with exception', function () {
|
|
$contextProvider = new ExceptionContextProvider();
|
|
|
|
$exception = new RuntimeException('Will be garbage collected');
|
|
$contextData = new ExceptionContextData(
|
|
operation: 'test',
|
|
component: 'Test',
|
|
requestId: 'test',
|
|
occurredAt: new \DateTimeImmutable()
|
|
);
|
|
|
|
$contextProvider->attach($exception, $contextData);
|
|
|
|
// Verify context exists
|
|
expect($contextProvider->get($exception))->not->toBeNull();
|
|
|
|
// Remove all references to exception
|
|
unset($exception);
|
|
|
|
// Context is automatically garbage collected with exception
|
|
// (WeakMap behavior - cannot directly test GC, but semantically correct)
|
|
expect(true)->toBeTrue(); // Placeholder for semantic correctness
|
|
});
|
|
});
|
|
|
|
describe('Context enrichment with boundary metadata', function () {
|
|
it('enriches exception context with boundary metadata', function () {
|
|
$contextProvider = new ExceptionContextProvider();
|
|
$exception = new RuntimeException('Boundary error');
|
|
|
|
// Simulate ErrorBoundary enrichment
|
|
$initialContext = new ExceptionContextData(
|
|
operation: 'user.operation',
|
|
component: 'UserService',
|
|
requestId: 'req-abc',
|
|
occurredAt: new \DateTimeImmutable()
|
|
);
|
|
$contextProvider->attach($exception, $initialContext);
|
|
|
|
// ErrorBoundary enriches with boundary metadata
|
|
$existingContext = $contextProvider->get($exception);
|
|
$enrichedContext = $existingContext->addMetadata([
|
|
'error_boundary' => 'user_boundary',
|
|
'boundary_failure' => true,
|
|
'fallback_executed' => true
|
|
]);
|
|
$contextProvider->attach($exception, $enrichedContext);
|
|
|
|
// Retrieve and verify enriched context
|
|
$finalContext = $contextProvider->get($exception);
|
|
expect($finalContext->metadata['error_boundary'])->toBe('user_boundary');
|
|
expect($finalContext->metadata['boundary_failure'])->toBeTrue();
|
|
expect($finalContext->metadata['fallback_executed'])->toBeTrue();
|
|
});
|
|
|
|
it('preserves original context when enriching with HTTP fields', function () {
|
|
$contextProvider = new ExceptionContextProvider();
|
|
$exception = new RuntimeException('HTTP error');
|
|
|
|
$initialContext = new ExceptionContextData(
|
|
operation: 'api.request',
|
|
component: 'ApiController',
|
|
requestId: 'req-http-123',
|
|
occurredAt: new \DateTimeImmutable()
|
|
);
|
|
$contextProvider->attach($exception, $initialContext);
|
|
|
|
// Enrich with HTTP-specific fields
|
|
$existingContext = $contextProvider->get($exception);
|
|
$enrichedContext = $existingContext->addMetadata([
|
|
'client_ip' => '192.168.1.100',
|
|
'user_agent' => 'Mozilla/5.0',
|
|
'http_method' => 'POST',
|
|
'request_uri' => '/api/users'
|
|
]);
|
|
$contextProvider->attach($exception, $enrichedContext);
|
|
|
|
// Verify both original and enriched data
|
|
$finalContext = $contextProvider->get($exception);
|
|
expect($finalContext->operation)->toBe('api.request');
|
|
expect($finalContext->component)->toBe('ApiController');
|
|
expect($finalContext->metadata['client_ip'])->toBe('192.168.1.100');
|
|
expect($finalContext->metadata['user_agent'])->toBe('Mozilla/5.0');
|
|
});
|
|
});
|
|
|
|
describe('End-to-end integration scenario', function () {
|
|
beforeEach(function () {
|
|
// Mock $_SERVER for API detection
|
|
$_SERVER['HTTP_ACCEPT'] = 'application/json';
|
|
$_SERVER['REQUEST_URI'] = '/api/test';
|
|
});
|
|
|
|
afterEach(function () {
|
|
// Cleanup
|
|
unset($_SERVER['HTTP_ACCEPT'], $_SERVER['REQUEST_URI']);
|
|
});
|
|
|
|
it('demonstrates full exception handling flow with context enrichment', function () {
|
|
// Setup
|
|
$errorKernel = createTestErrorKernel();
|
|
$contextProvider = new ExceptionContextProvider();
|
|
|
|
// 1. Exception occurs in service layer
|
|
$exception = new RuntimeException('User registration failed', 500);
|
|
|
|
// 2. Service enriches with operation context
|
|
$serviceContext = new ExceptionContextData(
|
|
operation: 'user.register',
|
|
component: 'UserService',
|
|
requestId: 'req-registration-789',
|
|
occurredAt: new \DateTimeImmutable(),
|
|
metadata: ['user_email' => 'test@example.com']
|
|
);
|
|
$contextProvider->attach($exception, $serviceContext);
|
|
|
|
// 3. ErrorBoundary catches and enriches with boundary metadata
|
|
$boundaryContext = $contextProvider->get($exception)->addMetadata([
|
|
'error_boundary' => 'user_registration_boundary',
|
|
'boundary_failure' => true,
|
|
'fallback_executed' => false
|
|
]);
|
|
$contextProvider->attach($exception, $boundaryContext);
|
|
|
|
// 4. HTTP layer enriches with request metadata
|
|
$httpContext = $contextProvider->get($exception)->addMetadata([
|
|
'client_ip' => '203.0.113.42',
|
|
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0)',
|
|
'http_method' => 'POST',
|
|
'request_uri' => '/api/users/register'
|
|
]);
|
|
$contextProvider->attach($exception, $httpContext);
|
|
|
|
// 5. ErrorKernel generates HTTP response
|
|
$response = $errorKernel->createHttpResponse($exception, $contextProvider, isDebugMode: true);
|
|
|
|
// 6. Verify complete context propagation
|
|
$body = json_decode($response->body, true);
|
|
|
|
expect($body['context']['operation'])->toBe('user.register');
|
|
expect($body['context']['component'])->toBe('UserService');
|
|
expect($body['context']['request_id'])->toBe('req-registration-789');
|
|
|
|
// Note: Metadata is stored in ExceptionContextData but not automatically
|
|
// included in ResponseErrorRenderer output (by design for production safety)
|
|
// Metadata can be accessed programmatically via $contextProvider->get($exception)->metadata
|
|
|
|
expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR);
|
|
expect($response->headers->getFirst('Content-Type'))->toBe('application/json');
|
|
});
|
|
});
|