auditLogger = new InMemoryAuditLogger(); $this->clock = new class implements Clock { private DateTimeImmutable $now; public function __construct() { $this->now = new DateTimeImmutable('2024-01-01 12:00:00'); } public function now(): DateTimeImmutable { return $this->now; } public function fromTimestamp(\App\Framework\Core\ValueObjects\Timestamp $timestamp): DateTimeImmutable { return DateTimeImmutable::createFromFormat('U', (string) $timestamp->value); } public function fromString(string $dateTime, ?string $format = null): DateTimeImmutable { return DateTimeImmutable::createFromFormat($format ?? 'Y-m-d H:i:s', $dateTime); } public function today(): DateTimeImmutable { return $this->now; } public function yesterday(): DateTimeImmutable { return $this->now->modify('-1 day'); } public function tomorrow(): DateTimeImmutable { return $this->now->modify('+1 day'); } public function time(): \App\Framework\Core\ValueObjects\Timestamp { return new \App\Framework\Core\ValueObjects\Timestamp($this->now->getTimestamp()); } }; $this->contextProvider = new ExceptionContextProvider(); $this->exceptionAuditLogger = new ExceptionAuditLogger( $this->auditLogger, $this->clock, $this->contextProvider ); $this->errorScope = new ErrorScope(); $this->factory = new ExceptionFactory($this->contextProvider, $this->errorScope); }); it('automatically logs auditable exceptions through ErrorKernel', function () { $reporter = new class implements Reporter { public function report(\Throwable $exception): void { // Mock reporter } }; $rendererFactory = new ErrorRendererFactory(false); $errorKernel = new ErrorKernel( rendererFactory: $rendererFactory, reporter: $reporter, contextProvider: $this->contextProvider, auditLogger: $this->exceptionAuditLogger ); // Create auditable exception $exception = $this->factory->createAuditable( RuntimeException::class, 'User creation failed', 'user.create', 'UserService', ['user_id' => '123'] ); // Handle exception through ErrorKernel $errorKernel->handle($exception); // Check that audit entry was created $entries = getAllAuditEntries($this->auditLogger); expect($entries)->toHaveCount(1); $entry = $entries[0]; expect($entry->success)->toBeFalse(); expect($entry->errorMessage)->toBe('User creation failed'); expect($entry->entityType)->toBe('userservice'); }); it('does not log non-auditable exceptions through ErrorKernel', function () { $reporter = new class implements Reporter { public function report(\Throwable $exception): void { // Mock reporter } }; $rendererFactory = new ErrorRendererFactory(false); $errorKernel = new ErrorKernel( rendererFactory: $rendererFactory, reporter: $reporter, contextProvider: $this->contextProvider, auditLogger: $this->exceptionAuditLogger ); // Create non-auditable exception $exception = $this->factory->createNonAuditable( RuntimeException::class, 'Validation error' ); // Handle exception through ErrorKernel $errorKernel->handle($exception); // Check that no audit entry was created $entries = getAllAuditEntries($this->auditLogger); expect($entries)->toHaveCount(0); }); it('logs exception with audit level through factory', function () { $reporter = new class implements Reporter { public function report(\Throwable $exception): void { // Mock reporter } }; $rendererFactory = new ErrorRendererFactory(false); $errorKernel = new ErrorKernel( rendererFactory: $rendererFactory, reporter: $reporter, contextProvider: $this->contextProvider, auditLogger: $this->exceptionAuditLogger ); // Create exception with audit level $exception = $this->factory->withAuditLevel( RuntimeException::class, 'Warning: Resource limit reached', 'WARNING' ); // Handle exception $errorKernel->handle($exception); // Check that audit entry was created with level $entries = getAllAuditEntries($this->auditLogger); expect($entries)->toHaveCount(1); $entry = $entries[0]; $context = $this->contextProvider->get($exception); expect($context)->not->toBeNull(); expect($context->auditLevel)->toBe('WARNING'); }); it('preserves all context information in audit entry', function () { $reporter = new class implements Reporter { public function report(\Throwable $exception): void { // Mock reporter } }; $rendererFactory = new ErrorRendererFactory(false); $errorKernel = new ErrorKernel( rendererFactory: $rendererFactory, reporter: $reporter, contextProvider: $this->contextProvider, auditLogger: $this->exceptionAuditLogger ); // Create exception with full context (using Value Objects) $context = ExceptionContextData::forOperation('payment.process', 'PaymentGateway') ->addData(['order_id' => 'order-123', 'amount' => 5000]) ->withUserId('user-456') ->withRequestId('req-789') // String for backward compatibility (RequestId needs secret) ->withSessionId(\App\Framework\Http\Session\SessionId::fromString('session-abc')) ->withClientIp(\App\Framework\Http\IpAddress::from('192.168.1.100')) ->withUserAgent(\App\Framework\UserAgent\UserAgent::fromString('Mozilla/5.0')) ->withTags('payment', 'external_api', 'critical') ->withAuditable(true); $exception = $this->factory->create( RuntimeException::class, 'Payment processing failed', $context ); // Handle exception $errorKernel->handle($exception); // Check audit entry $entries = getAllAuditEntries($this->auditLogger); expect($entries)->toHaveCount(1); $entry = $entries[0]; expect($entry->userId)->toBe('user-456'); expect($entry->entityId)->toBe('order-123'); expect($entry->ipAddress)->not->toBeNull(); expect((string) $entry->ipAddress)->toBe('192.168.1.100'); expect($entry->userAgent)->not->toBeNull(); expect($entry->userAgent->value)->toBe('Mozilla/5.0'); expect($entry->metadata)->toHaveKey('request_id'); expect($entry->metadata['request_id'])->toBe('req-789'); expect($entry->metadata)->toHaveKey('session_id'); expect($entry->metadata['session_id'])->toBe('session-abc'); expect($entry->metadata)->toHaveKey('tags'); expect($entry->metadata['tags'])->toContain('payment', 'external_api', 'critical'); }); }); /** * Helper to get all audit entries from InMemoryAuditLogger * * @return array */ function getAllAuditEntries(AuditLogger $logger): array { if ($logger instanceof InMemoryAuditLogger) { // Use reflection to access private entries property $reflection = new ReflectionClass($logger); $property = $reflection->getProperty('entries'); $property->setAccessible(true); return array_values($property->getValue($logger)); } // For other implementations, query all entries $query = new \App\Framework\Audit\ValueObjects\AuditQuery(); return $logger->query($query); }