timestamp = new DateTimeImmutable('2024-01-15 10:30:45', new DateTimeZone('Europe/Berlin')); }); describe('constructor', function () { it('accepts default configuration', function () { $formatter = new JsonFormatter(); expect($formatter instanceof JsonFormatter)->toBeTrue(); }); it('accepts pretty print option', function () { $formatter = new JsonFormatter(prettyPrint: true); expect($formatter instanceof JsonFormatter)->toBeTrue(); }); it('accepts include extras option', function () { $formatter = new JsonFormatter(includeExtras: false); expect($formatter instanceof JsonFormatter)->toBeTrue(); }); it('accepts both options', function () { $formatter = new JsonFormatter( prettyPrint: true, includeExtras: true ); expect($formatter instanceof JsonFormatter)->toBeTrue(); }); }); describe('basic JSON formatting', function () { it('returns valid JSON string', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); expect($result)->toBeString(); $decoded = json_decode($result, true); expect($decoded)->toBeArray(); }); it('includes timestamp in ISO 8601 format', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['timestamp'])->toContain('2024-01-15T10:30:45'); }); it('includes log level name', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::WARNING, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['level'])->toBe('WARNING'); }); it('includes log level value', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::ERROR, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['level_value'])->toBe(400); }); it('includes channel', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp, channel: 'security' ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['channel'])->toBe('security'); }); it('includes message', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Important message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['message'])->toBe('Important message'); }); it('includes context', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::withData([ 'user_id' => 123, 'action' => 'login' ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['context']['user_id'])->toBe(123); expect($decoded['context']['action'])->toBe('login'); }); it('handles empty context', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['context'])->toBeArray(); expect($decoded['context'])->toBeEmpty(); }); }); describe('pretty print option', function () { it('formats JSON with indentation when enabled', function () { $formatter = new JsonFormatter(prettyPrint: true); $record = new LogRecord( message: 'Test message', context: LogContext::withData(['key' => 'value']), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); // Pretty printed JSON should contain newlines and spaces expect($result)->toContain("\n"); expect($result)->toContain(' '); // Indentation spaces }); it('formats JSON as single line when disabled', function () { $formatter = new JsonFormatter(prettyPrint: false); $record = new LogRecord( message: 'Test message', context: LogContext::withData(['key' => 'value']), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); // Not pretty printed should be single line (may contain \n in strings, but not formatting \n) $lines = explode("\n", $result); expect(count($lines))->toBeLessThan(3); }); }); describe('include extras option', function () { it('includes extras when enabled', function () { $formatter = new JsonFormatter(includeExtras: true); $record = (new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ))->addExtra('request_id', 'req-123') ->addExtra('session_id', 'sess-456'); $result = $formatter($record); $decoded = json_decode($result, true); expect(isset($decoded['extra']))->toBeTrue(); expect($decoded['extra']['request_id'])->toBe('req-123'); expect($decoded['extra']['session_id'])->toBe('sess-456'); }); it('excludes extras when disabled', function () { $formatter = new JsonFormatter(includeExtras: false); $record = (new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ))->addExtra('request_id', 'req-123'); $result = $formatter($record); $decoded = json_decode($result, true); expect(isset($decoded['extra']))->toBeFalse(); }); it('omits extras key when no extras and enabled', function () { $formatter = new JsonFormatter(includeExtras: true); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); // Should not include 'extra' key if there are no extras expect(isset($decoded['extra']))->toBeFalse(); }); }); describe('context handling', function () { it('handles nested context arrays', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::withData([ 'user' => [ 'id' => 123, 'name' => 'John Doe', 'roles' => ['admin', 'user'] ] ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['context']['user']['id'])->toBe(123); expect($decoded['context']['user']['name'])->toBe('John Doe'); expect($decoded['context']['user']['roles'])->toBe(['admin', 'user']); }); it('handles unicode characters', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Unicode test', context: LogContext::withData([ 'chinese' => '你好', 'russian' => 'привет', 'emoji' => '🌍' ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['context']['chinese'])->toBe('你好'); expect($decoded['context']['russian'])->toBe('привет'); expect($decoded['context']['emoji'])->toBe('🌍'); }); it('handles special characters', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Special chars test', context: LogContext::withData([ 'quotes' => 'He said "hello"', 'backslashes' => 'Path\\to\\file', 'newlines' => "Line1\nLine2" ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['context']['quotes'])->toBe('He said "hello"'); expect($decoded['context']['backslashes'])->toBe('Path\\to\\file'); expect($decoded['context']['newlines'])->toBe("Line1\nLine2"); }); it('preserves numeric types', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::withData([ 'integer' => 42, 'float' => 3.14, 'boolean' => true, 'null' => null ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['context']['integer'])->toBe(42); expect($decoded['context']['float'])->toBe(3.14); expect($decoded['context']['boolean'])->toBeTrue(); expect($decoded['context']['null'])->toBeNull(); }); }); describe('JSON flags', function () { it('uses unescaped slashes', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::withData([ 'url' => 'https://example.com/path' ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); // JSON_UNESCAPED_SLASHES: slashes should not be escaped expect($result)->toContain('https://example.com/path'); expect(str_contains($result, '\\/'))->toBeFalse(); }); it('uses unescaped unicode', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Unicode message', context: LogContext::withData([ 'text' => '你好世界' ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); // JSON_UNESCAPED_UNICODE: should contain actual unicode chars, not \uXXXX expect($result)->toContain('你好世界'); expect(str_contains($result, '\\u'))->toBeFalse(); }); }); describe('log levels', function () { it('formats DEBUG level', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Debug message', context: LogContext::empty(), level: LogLevel::DEBUG, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['level'])->toBe('DEBUG'); expect($decoded['level_value'])->toBe(100); }); it('formats INFO level', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Info message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['level'])->toBe('INFO'); expect($decoded['level_value'])->toBe(200); }); it('formats EMERGENCY level', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Emergency message', context: LogContext::empty(), level: LogLevel::EMERGENCY, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['level'])->toBe('EMERGENCY'); expect($decoded['level_value'])->toBe(600); }); }); describe('readonly behavior', function () { it('is a readonly class', function () { $reflection = new ReflectionClass(JsonFormatter::class); expect($reflection->isReadOnly())->toBeTrue(); }); }); describe('edge cases', function () { it('handles empty message', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: '', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['message'])->toBe(''); }); it('handles very long message', function () { $formatter = new JsonFormatter(); $longMessage = str_repeat('x', 10000); $record = new LogRecord( message: $longMessage, context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['message'])->toHaveLength(10000); }); it('handles null channel', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: $this->timestamp, channel: null ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['channel'])->toBeNull(); }); it('handles deeply nested context', function () { $formatter = new JsonFormatter(); $record = new LogRecord( message: 'Test message', context: LogContext::withData([ 'level1' => [ 'level2' => [ 'level3' => [ 'level4' => 'deep value' ] ] ] ]), level: LogLevel::INFO, timestamp: $this->timestamp ); $result = $formatter($record); $decoded = json_decode($result, true); expect($decoded['context']['level1']['level2']['level3']['level4'])->toBe('deep value'); }); }); });