timestamp = new DateTimeImmutable('2024-01-15 10:30:45', new DateTimeZone('Europe/Berlin')); }); describe('constructor', function () { it('accepts default format', function () { $formatter = new StructuredFormatter(); expect($formatter instanceof StructuredFormatter)->toBeTrue(); }); it('accepts logfmt format', function () { $formatter = new StructuredFormatter(format: 'logfmt'); expect($formatter instanceof StructuredFormatter)->toBeTrue(); }); it('accepts kv format', function () { $formatter = new StructuredFormatter(format: 'kv'); expect($formatter instanceof StructuredFormatter)->toBeTrue(); }); it('accepts array format', function () { $formatter = new StructuredFormatter(format: 'array'); expect($formatter instanceof StructuredFormatter)->toBeTrue(); }); }); describe('logfmt format', function () { it('formats as logfmt string', function () { $formatter = new StructuredFormatter(format: 'logfmt'); $record = new LogRecord( message: 'Test message', context: LogContext::withData(['user_id' => 123]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result)->toBeString(); // Implementation uses abbreviated keys: ts, level, msg expect(str_contains($result, 'level=INFO'))->toBeTrue(); expect(str_contains($result, 'msg="Test message"'))->toBeTrue(); expect(str_contains($result, 'user_id=123'))->toBeTrue(); }); it('quotes values with spaces', function () { $formatter = new StructuredFormatter(format: 'logfmt'); $record = new LogRecord( message: 'Multi word message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect(str_contains($result, 'msg="Multi word message"'))->toBeTrue(); }); it('quotes values with equals sign', function () { $formatter = new StructuredFormatter(format: 'logfmt'); $record = new LogRecord( message: 'Test', context: LogContext::withData(['equation' => '2+2=4']), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect(str_contains($result, 'equation="2+2=4"'))->toBeTrue(); }); it('escapes quotes in values', function () { $formatter = new StructuredFormatter(format: 'logfmt'); $record = new LogRecord( message: 'Test', context: LogContext::withData(['quote' => 'He said "hello"']), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect(str_contains($result, 'quote="He said \\"hello\\""'))->toBeTrue(); }); it('includes timestamp', function () { $formatter = new StructuredFormatter(format: 'logfmt'); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); // Implementation uses 'ts' key expect(str_contains($result, 'ts='))->toBeTrue(); expect(str_contains($result, '2024-01-15'))->toBeTrue(); }); it('includes channel when provided', function () { $formatter = new StructuredFormatter(format: 'logfmt'); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp, channel: 'security' ); $result = $formatter($record); expect(str_contains($result, 'channel=security'))->toBeTrue(); }); }); describe('kv format', function () { it('formats as key-value string', function () { $formatter = new StructuredFormatter(format: 'kv'); $record = new LogRecord( message: 'Test message', context: LogContext::withData(['user_id' => 123]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result)->toBeString(); expect(str_contains($result, 'level: INFO'))->toBeTrue(); expect(str_contains($result, 'msg: Test message'))->toBeTrue(); expect(str_contains($result, 'user_id: 123'))->toBeTrue(); }); it('uses colon separator', function () { $formatter = new StructuredFormatter(format: 'kv'); $record = new LogRecord( message: 'Test', context: LogContext::withData(['key' => 'value']), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect(str_contains($result, 'key: value'))->toBeTrue(); expect(str_contains($result, '='))->toBeFalse(); }); }); describe('array format', function () { it('returns array', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result)->toBeArray(); }); it('includes all standard fields', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp, channel: 'app' ); $result = $formatter($record); // Implementation uses abbreviated keys expect(isset($result['ts']))->toBeTrue(); expect(isset($result['level']))->toBeTrue(); expect(isset($result['msg']))->toBeTrue(); expect(isset($result['channel']))->toBeTrue(); }); it('includes context data', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test message', context: LogContext::withData([ 'user_id' => 123, 'action' => 'login' ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['user_id'])->toBe('123'); // Sanitized to string expect($result['action'])->toBe('login'); }); it('includes extras', function () { $formatter = new StructuredFormatter(format: 'array'); $record = (new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ))->addExtra('request_id', 'req-123'); $result = $formatter($record); expect($result['request_id'])->toBe('req-123'); }); it('flattens context and extras', function () { $formatter = new StructuredFormatter(format: 'array'); $record = (new LogRecord( message: 'Test message', context: LogContext::withData(['ctx_key' => 'ctx_value']), level: LogLevel::INFO, timestamp: $this->timestamp ))->addExtra('extra_key', 'extra_value'); $result = $formatter($record); expect($result['ctx_key'])->toBe('ctx_value'); expect($result['extra_key'])->toBe('extra_value'); }); }); describe('key sanitization', function () { it('removes leading underscores', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test', context: LogContext::withData([ '_internal' => 'value' ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect(isset($result['internal']))->toBeTrue(); }); it('makes keys aggregation friendly', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test', context: LogContext::withData([ 'some.nested.key' => 'value1', 'another-key' => 'value2' ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect(isset($result['some_nested_key']))->toBeTrue(); expect(isset($result['another_key']))->toBeTrue(); }); }); describe('value sanitization', function () { it('handles string values', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test', context: LogContext::withData(['text' => 'simple string']), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['text'])->toBe('simple string'); }); it('converts numeric values to strings', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test', context: LogContext::withData([ 'integer' => 42, 'float' => 3.14 ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['integer'])->toBe('42'); expect($result['float'])->toBe('3.14'); }); it('converts boolean values to strings', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test', context: LogContext::withData([ 'success' => true, 'failed' => false ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['success'])->toBe('true'); expect($result['failed'])->toBe('false'); }); it('converts null to string', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test', context: LogContext::withData(['nullable' => null]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['nullable'])->toBe('null'); }); it('converts arrays to JSON strings', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test', context: LogContext::withData([ 'items' => ['item1', 'item2', 'item3'] ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['items'])->toBeString(); expect(str_contains($result['items'], 'item1'))->toBeTrue(); expect(str_contains($result['items'], 'item2'))->toBeTrue(); }); it('converts objects to class name', function () { $formatter = new StructuredFormatter(format: 'array'); $obj = new stdClass(); $obj->prop = 'value'; $record = new LogRecord( message: 'Test', context: LogContext::withData(['object' => $obj]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['object'])->toBe('stdClass'); }); }); describe('log levels', function () { it('formats DEBUG level', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Debug', context: LogContext::empty(), level: LogLevel::DEBUG, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['level'])->toBe('DEBUG'); }); it('formats all log levels correctly', function () { $formatter = new StructuredFormatter(format: 'array'); $levels = [ LogLevel::DEBUG, LogLevel::INFO, LogLevel::NOTICE, LogLevel::WARNING, LogLevel::ERROR, LogLevel::CRITICAL, LogLevel::ALERT, LogLevel::EMERGENCY ]; foreach ($levels as $level) { $record = new LogRecord( message: 'Test', context: LogContext::empty(), level: $level, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['level'])->toBe($level->getName()); } }); }); describe('readonly behavior', function () { it('is a readonly class', function () { $reflection = new ReflectionClass(StructuredFormatter::class); expect($reflection->isReadOnly())->toBeTrue(); }); }); describe('edge cases', function () { it('handles empty context and extras', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); // Should still include standard fields expect(isset($result['ts']))->toBeTrue(); expect(isset($result['level']))->toBeTrue(); expect(isset($result['msg']))->toBeTrue(); }); it('handles very long values', function () { $formatter = new StructuredFormatter(format: 'array'); $longValue = str_repeat('x', 10000); $record = new LogRecord( message: $longValue, context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result['msg'])->toHaveLength(10000); }); it('handles unicode characters', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Unicode: 你好 🌍', context: LogContext::withData(['key' => 'мир']), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect(str_contains($result['msg'], '你好'))->toBeTrue(); expect(str_contains($result['msg'], '🌍'))->toBeTrue(); expect($result['key'])->toBe('мир'); }); it('handles null channel', function () { $formatter = new StructuredFormatter(format: 'array'); $record = new LogRecord( message: 'Test', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp, channel: null ); $result = $formatter($record); // Channel should not be in array if null expect(isset($result['channel']))->toBeFalse(); }); }); });