contextProvider = ExceptionContextProvider::instance(); $this->errorScope = new ErrorScope(); $this->factory = new ExceptionFactory($this->contextProvider, $this->errorScope); // Clear any existing contexts $this->contextProvider->clear(); }); it('creates slim exception with external context via WeakMap', function () { $context = ExceptionContextData::forOperation('user.create', 'UserService') ->addData(['user_id' => '123', 'email' => 'test@example.com']); $exception = $this->factory->create( RuntimeException::class, 'User creation failed', $context ); // Exception is slim (pure PHP) expect($exception)->toBeInstanceOf(RuntimeException::class); expect($exception->getMessage())->toBe('User creation failed'); // Context is stored externally $storedContext = $this->contextProvider->get($exception); expect($storedContext)->not->toBeNull(); expect($storedContext->operation)->toBe('user.create'); expect($storedContext->component)->toBe('UserService'); expect($storedContext->data)->toBe([ 'user_id' => '123', 'email' => 'test@example.com' ]); }); it('automatically enriches context from error scope', function () { // Enter HTTP scope $scopeContext = ErrorScopeContext::http( request: createMockRequest(), operation: 'api.request', component: 'ApiController' )->withUserId('user-456'); $this->errorScope->enter($scopeContext); // Create exception without explicit context $exception = $this->factory->create( RuntimeException::class, 'API request failed' ); // Context is enriched from scope $storedContext = $this->contextProvider->get($exception); expect($storedContext)->not->toBeNull(); expect($storedContext->userId)->toBe('user-456'); expect($storedContext->metadata)->toHaveKey('scope_type'); expect($storedContext->metadata['scope_type'])->toBe('http'); }); it('supports WeakMap automatic garbage collection', function () { $exception = new RuntimeException('Test exception'); $context = ExceptionContextData::forOperation('test.operation'); $this->contextProvider->attach($exception, $context); // Context exists expect($this->contextProvider->has($exception))->toBeTrue(); // Unset exception reference unset($exception); // Force garbage collection gc_collect_cycles(); // WeakMap automatically cleaned up (we can't directly test this, // but stats should reflect fewer contexts after GC) $stats = $this->contextProvider->getStats(); expect($stats)->toHaveKey('total_contexts'); }); it('enhances existing exception with additional context', function () { $exception = new RuntimeException('Original error'); $originalContext = ExceptionContextData::forOperation('operation.1') ->addData(['step' => 1]); $this->contextProvider->attach($exception, $originalContext); // Enhance with additional context $additionalContext = ExceptionContextData::empty() ->addData(['step' => 2, 'error_code' => 'E001']); $this->factory->enhance($exception, $additionalContext); // Context is merged $storedContext = $this->contextProvider->get($exception); expect($storedContext->data)->toBe([ 'step' => 2, // Overwrites 'error_code' => 'E001' // Adds ]); }); it('supports fiber-aware error scopes', function () { // Main scope $mainScope = ErrorScopeContext::generic( 'main', 'main.operation' ); $this->errorScope->enter($mainScope); // Create fiber scope $fiber = new Fiber(function () { $fiberScope = ErrorScopeContext::generic( 'fiber', 'fiber.operation' ); $this->errorScope->enter($fiberScope); $exception = $this->factory->create( RuntimeException::class, 'Fiber error' ); // Context from fiber scope $context = $this->contextProvider->get($exception); expect($context->operation)->toBe('fiber.operation'); $this->errorScope->exit(); }); $fiber->start(); // Main scope still active $exception = $this->factory->create( RuntimeException::class, 'Main error' ); $context = $this->contextProvider->get($exception); expect($context->operation)->toBe('main.operation'); }); it('creates exception with convenience factory methods', function () { // forOperation $exception1 = $this->factory->forOperation( InvalidArgumentException::class, 'Invalid user data', 'user.validate', 'UserValidator', ['email' => 'invalid'] ); $context1 = $this->contextProvider->get($exception1); expect($context1->operation)->toBe('user.validate'); expect($context1->component)->toBe('UserValidator'); expect($context1->data['email'])->toBe('invalid'); // withData $exception2 = $this->factory->withData( RuntimeException::class, 'Database error', ['query' => 'SELECT * FROM users'] ); $context2 = $this->contextProvider->get($exception2); expect($context2->data['query'])->toBe('SELECT * FROM users'); }); it('serializes Value Objects to strings in toArray()', function () { $context = ExceptionContextData::forOperation('test.operation') ->withClientIp(\App\Framework\Http\IpAddress::from('192.168.1.1')) ->withUserAgent(\App\Framework\UserAgent\UserAgent::fromString('Mozilla/5.0')) ->withSessionId(\App\Framework\Http\Session\SessionId::fromString('session-12345678901234567890123456789012')) ->withRequestId('req-123'); $array = $context->toArray(); expect($array['client_ip'])->toBe('192.168.1.1'); expect($array['user_agent'])->toBe('Mozilla/5.0'); expect($array['session_id'])->toBe('session-12345678901234567890123456789012'); expect($array['request_id'])->toBe('req-123'); }); it('serializes strings to strings in toArray() (backward compatibility)', function () { $context = ExceptionContextData::forOperation('test.operation') ->withClientIp('192.168.1.1') ->withUserAgent('Mozilla/5.0') ->withSessionId('session-123') ->withRequestId('req-123'); $array = $context->toArray(); expect($array['client_ip'])->toBe('192.168.1.1'); expect($array['user_agent'])->toBe('Mozilla/5.0'); expect($array['session_id'])->toBe('session-123'); expect($array['request_id'])->toBe('req-123'); }); it('handles nested error scopes correctly', function () { // Outer scope $outerScope = ErrorScopeContext::http( request: createMockRequest(), operation: 'outer.operation' ); $this->errorScope->enter($outerScope); // Inner scope $innerScope = ErrorScopeContext::generic( 'inner', 'inner.operation' ); $this->errorScope->enter($innerScope); // Exception gets inner scope context $exception = $this->factory->create( RuntimeException::class, 'Inner error' ); $context = $this->contextProvider->get($exception); expect($context->operation)->toBe('inner.operation'); // Exit inner scope $this->errorScope->exit(); // New exception gets outer scope context $exception2 = $this->factory->create( RuntimeException::class, 'Outer error' ); $context2 = $this->contextProvider->get($exception2); expect($context2->operation)->toBe('outer.operation'); }); }); // Helper function to create mock request function createMockRequest(): \App\Framework\Http\HttpRequest { return new \App\Framework\Http\HttpRequest( method: \App\Framework\Http\Method::GET, path: '/test', id: new \App\Framework\Http\RequestId('test-secret') ); }