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::createFromTimestamp($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 ); }); it('logs auditable exception as audit entry', function () { $exception = new RuntimeException('User not found'); $context = ExceptionContextData::forOperation('user.lookup', 'UserRepository') ->addData(['user_id' => '123']) ->withUserId('user-456') ->withAuditable(true); $this->contextProvider->attach($exception, $context); $this->exceptionAuditLogger->logIfAuditable($exception); // Check that audit entry was created $entries = $this->getAllAuditEntries($this->auditLogger); expect($entries)->toHaveCount(1); $entry = $entries[0]; expect($entry->success)->toBeFalse(); expect($entry->errorMessage)->toBe('User not found'); expect($entry->userId)->toBe('user-456'); expect($entry->entityType)->toBe('userrepository'); expect($entry->action)->toBeInstanceOf(AuditableAction::class); }); it('does not log non-auditable exception', function () { $exception = new RuntimeException('Validation error'); $context = ExceptionContextData::forOperation('validation.check') ->withAuditable(false); $this->contextProvider->attach($exception, $context); $this->exceptionAuditLogger->logIfAuditable($exception); // Check that no audit entry was created $entries = $this->getAllAuditEntries($this->auditLogger); expect($entries)->toHaveCount(0); }); it('logs exception without context as auditable by default', function () { $exception = new RuntimeException('Generic error'); $this->exceptionAuditLogger->logIfAuditable($exception); // Check that audit entry was created (default: auditable) $entries = $this->getAllAuditEntries($this->auditLogger); expect($entries)->toHaveCount(1); }); it('includes context data in audit entry metadata', function () { $exception = new RuntimeException('Operation failed'); $context = ExceptionContextData::forOperation('order.process', 'OrderService') ->addData(['order_id' => 'order-789', 'amount' => 1000]) ->withUserId('user-123') ->withRequestId('req-456') ->withTags('payment', 'external_api'); $this->contextProvider->attach($exception, $context); $this->exceptionAuditLogger->logIfAuditable($exception); $entries = $this->getAllAuditEntries($this->auditLogger); $entry = $entries[0]; expect($entry->metadata)->toHaveKey('operation'); expect($entry->metadata['operation'])->toBe('order.process'); expect($entry->metadata)->toHaveKey('component'); expect($entry->metadata['component'])->toBe('OrderService'); expect($entry->metadata)->toHaveKey('context_data'); expect($entry->metadata['context_data']['order_id'])->toBe('order-789'); expect($entry->metadata)->toHaveKey('tags'); expect($entry->metadata['tags'])->toBe(['payment', 'external_api']); }); it('determines audit action from operation name', function () { $testCases = [ ['operation' => 'user.create', 'expected' => AuditableAction::CREATE], ['operation' => 'order.update', 'expected' => AuditableAction::UPDATE], ['operation' => 'product.delete', 'expected' => AuditableAction::DELETE], ['operation' => 'data.read', 'expected' => AuditableAction::READ], ]; foreach ($testCases as $testCase) { $exception = new RuntimeException('Test'); $context = ExceptionContextData::forOperation($testCase['operation']) ->withAuditable(true); $this->contextProvider->attach($exception, $context); $this->exceptionAuditLogger->logIfAuditable($exception); $entries = $this->getAllAuditEntries($this->auditLogger); $entry = end($entries); expect($entry->action)->toBe($testCase['expected']); } }); it('extracts entity ID from context data', function () { $exception = new RuntimeException('Entity not found'); $context = ExceptionContextData::forOperation('entity.get') ->addData(['entity_id' => 'entity-123']) ->withAuditable(true); $this->contextProvider->attach($exception, $context); $this->exceptionAuditLogger->logIfAuditable($exception); $entries = $this->getAllAuditEntries($this->auditLogger); $entry = $entries[0]; expect($entry->entityId)->toBe('entity-123'); }); it('handles IP address and user agent from context with Value Objects', function () { $exception = new RuntimeException('Security violation'); $context = ExceptionContextData::forOperation('security.check') ->withClientIp(\App\Framework\Http\IpAddress::from('192.168.1.1')) ->withUserAgent(\App\Framework\UserAgent\UserAgent::fromString('Mozilla/5.0')) ->withAuditable(true); $this->contextProvider->attach($exception, $context); $this->exceptionAuditLogger->logIfAuditable($exception); $entries = $this->getAllAuditEntries($this->auditLogger); $entry = $entries[0]; expect($entry->ipAddress)->not->toBeNull(); expect((string) $entry->ipAddress)->toBe('192.168.1.1'); expect($entry->userAgent)->not->toBeNull(); expect($entry->userAgent->value)->toBe('Mozilla/5.0'); }); it('handles IP address and user agent from context with strings (backward compatibility)', function () { $exception = new RuntimeException('Security violation'); $context = ExceptionContextData::forOperation('security.check') ->withClientIp('192.168.1.1') ->withUserAgent('Mozilla/5.0') ->withAuditable(true); $this->contextProvider->attach($exception, $context); $this->exceptionAuditLogger->logIfAuditable($exception); $entries = $this->getAllAuditEntries($this->auditLogger); $entry = $entries[0]; expect($entry->ipAddress)->not->toBeNull(); expect((string) $entry->ipAddress)->toBe('192.168.1.1'); expect($entry->userAgent)->not->toBeNull(); expect($entry->userAgent->value)->toBe('Mozilla/5.0'); }); it('does not throw when audit logging fails', function () { $failingAuditLogger = new class implements AuditLogger { public function log(AuditEntry $entry): void { throw new RuntimeException('Audit logging failed'); } public function find(\App\Framework\Audit\ValueObjects\AuditId $id): ?AuditEntry { return null; } public function query(\App\Framework\Audit\ValueObjects\AuditQuery $query): array { return []; } public function count(\App\Framework\Audit\ValueObjects\AuditQuery $query): int { return 0; } public function purgeOlderThan(DateTimeImmutable $date): int { return 0; } }; $logger = new ExceptionAuditLogger( $failingAuditLogger, $this->clock, $this->contextProvider ); $exception = new RuntimeException('Test error'); $context = ExceptionContextData::forOperation('test') ->withAuditable(true); $this->contextProvider->attach($exception, $context); // Should not throw expect(fn() => $logger->logIfAuditable($exception))->not->toThrow(); }); it('includes previous exception in metadata', function () { $previous = new InvalidArgumentException('Invalid input'); $exception = new RuntimeException('Operation failed', 0, $previous); $context = ExceptionContextData::forOperation('operation.execute') ->withAuditable(true); $this->contextProvider->attach($exception, $context); $this->exceptionAuditLogger->logIfAuditable($exception); $entries = $this->getAllAuditEntries($this->auditLogger); $entry = $entries[0]; expect($entry->metadata)->toHaveKey('previous_exception'); expect($entry->metadata['previous_exception']['class'])->toBe(InvalidArgumentException::class); expect($entry->metadata['previous_exception']['message'])->toBe('Invalid input'); }); }); /** * 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); }