testDir = sys_get_temp_dir() . '/logger_json_test_' . uniqid(); mkdir($this->testDir, 0777, true); $this->testLogFile = $this->testDir . '/test.json'; }); afterEach(function () { // Clean up test files if (file_exists($this->testLogFile)) { unlink($this->testLogFile); } if (is_dir($this->testDir)) { rmdir($this->testDir); } }); describe('constructor', function () { it('creates log file directory if it does not exist', function () { $logFile = $this->testDir . '/nested/dir/test.json'; $handler = new JsonFileHandler($logFile); expect(is_dir(dirname($logFile)))->toBeTrue(); }); it('accepts LogLevel enum as minLevel', function () { $handler = new JsonFileHandler($this->testLogFile, LogLevel::ERROR); $record = new LogRecord( message: 'test message', context: LogContext::empty(), level: LogLevel::WARNING, timestamp: new DateTimeImmutable(), channel: 'test' ); expect($handler->isHandling($record))->toBeFalse(); }); it('accepts int as minLevel', function () { $handler = new JsonFileHandler($this->testLogFile, 400); // ERROR level $record = new LogRecord( message: 'test message', context: LogContext::empty(), level: LogLevel::WARNING, timestamp: new DateTimeImmutable(), channel: 'test' ); expect($handler->isHandling($record))->toBeFalse(); }); it('defaults to INFO level when no minLevel specified', function () { $handler = new JsonFileHandler($this->testLogFile); $infoRecord = new LogRecord( message: 'test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $debugRecord = new LogRecord( message: 'test message', context: LogContext::empty(), level: LogLevel::DEBUG, timestamp: new DateTimeImmutable(), channel: 'test' ); expect($handler->isHandling($infoRecord))->toBeTrue(); expect($handler->isHandling($debugRecord))->toBeFalse(); }); it('uses default fields when no includedFields specified', function () { $handler = new JsonFileHandler($this->testLogFile); $record = new LogRecord( message: 'Test message', context: LogContext::withData(['key' => 'value']), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); // Neue einheitliche Feldnamen (konsistent mit JsonFormatter) expect($json)->toHaveKeys(['timestamp', 'level', 'level_value', 'message', 'context', 'channel']); }); it('uses custom includedFields when specified', function () { $handler = new JsonFileHandler( $this->testLogFile, includedFields: ['level', 'message'] // Neue einheitliche Feldnamen ); $record = new LogRecord( message: 'Test message', context: LogContext::withData(['key' => 'value']), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); expect($json)->toHaveKeys(['level', 'message']); expect($json)->not->toHaveKey('context'); expect($json)->not->toHaveKey('channel'); }); }); describe('isHandling()', function () { it('returns true when record level is above minLevel', function () { $handler = new JsonFileHandler($this->testLogFile, LogLevel::WARNING); $record = new LogRecord( message: 'test message', context: LogContext::empty(), level: LogLevel::ERROR, timestamp: new DateTimeImmutable(), channel: 'test' ); expect($handler->isHandling($record))->toBeTrue(); }); it('returns true when record level equals minLevel', function () { $handler = new JsonFileHandler($this->testLogFile, LogLevel::WARNING); $record = new LogRecord( message: 'test message', context: LogContext::empty(), level: LogLevel::WARNING, timestamp: new DateTimeImmutable(), channel: 'test' ); expect($handler->isHandling($record))->toBeTrue(); }); it('returns false when record level is below minLevel', function () { $handler = new JsonFileHandler($this->testLogFile, LogLevel::ERROR); $record = new LogRecord( message: 'test message', context: LogContext::empty(), level: LogLevel::WARNING, timestamp: new DateTimeImmutable(), channel: 'test' ); expect($handler->isHandling($record))->toBeFalse(); }); }); describe('handle()', function () { it('writes valid JSON to file', function () { $handler = new JsonFileHandler($this->testLogFile); $record = new LogRecord( message: 'Test log message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record); expect(file_exists($this->testLogFile))->toBeTrue(); $content = file_get_contents($this->testLogFile); // Check valid JSON $json = json_decode(trim($content), true); expect($json)->not->toBeNull(); expect(json_last_error())->toBe(JSON_ERROR_NONE); }); it('includes message in JSON output', function () { $handler = new JsonFileHandler($this->testLogFile); $record = new LogRecord( message: 'Important message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); expect($json['message'])->toBe('Important message'); }); it('includes level in JSON output', function () { $handler = new JsonFileHandler($this->testLogFile); $record = new LogRecord( message: 'Error message', context: LogContext::empty(), level: LogLevel::ERROR, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); expect($json['level'])->toBe('ERROR'); }); it('includes channel in JSON output', function () { $handler = new JsonFileHandler($this->testLogFile); $record = new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'security' ); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); expect($json['channel'])->toBe('security'); }); it('includes context data in JSON output', function () { $handler = new JsonFileHandler($this->testLogFile); $record = new LogRecord( message: 'Test message', context: LogContext::withData(['user_id' => 123, 'action' => 'login']), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); // Context wird geflattened (nur structured data, ohne 'structured' wrapper) expect($json['context'])->toMatchArray(['user_id' => 123, 'action' => 'login']); }); it('includes extra data in JSON output', function () { $handler = new JsonFileHandler($this->testLogFile); $record = (new LogRecord( message: 'Test message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ))->addExtra('request_id', 'req-123'); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); expect($json['extra'])->toBe(['request_id' => 'req-123']); }); it('appends each log entry on new line', function () { $handler = new JsonFileHandler($this->testLogFile); $record1 = new LogRecord( message: 'First message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $record2 = new LogRecord( message: 'Second message', context: LogContext::empty(), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record1); $handler->handle($record2); $content = file_get_contents($this->testLogFile); $lines = explode(PHP_EOL, trim($content)); expect(count($lines))->toBe(2); $json1 = json_decode($lines[0], true); $json2 = json_decode($lines[1], true); expect($json1['message'])->toBe('First message'); expect($json2['message'])->toBe('Second message'); }); it('handles unicode characters correctly', function () { $handler = new JsonFileHandler($this->testLogFile); $record = new LogRecord( message: 'Übung mit Ümläüten und 日本語', context: LogContext::empty(), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); expect($json['message'])->toBe('Übung mit Ümläüten und 日本語'); }); it('only includes specified fields when includedFields is set', function () { $handler = new JsonFileHandler( $this->testLogFile, includedFields: ['level', 'message'] // Neue einheitliche Feldnamen ); $record = (new LogRecord( message: 'Test message', context: LogContext::withData(['key' => 'value']), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ))->withExtra('extra_key', 'extra_value'); // Immutable API $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); expect(array_keys($json))->toBe(['level', 'message']); }); }); describe('setMinLevel()', function () { it('updates minimum level with LogLevel', function () { $handler = new JsonFileHandler($this->testLogFile, LogLevel::DEBUG); $handler->setMinLevel(LogLevel::ERROR); $record = new LogRecord( message: 'test message', context: LogContext::empty(), level: LogLevel::WARNING, timestamp: new DateTimeImmutable(), channel: 'test' ); expect($handler->isHandling($record))->toBeFalse(); }); it('updates minimum level with int', function () { $handler = new JsonFileHandler($this->testLogFile, LogLevel::DEBUG); $handler->setMinLevel(400); // ERROR $record = new LogRecord( message: 'test message', context: LogContext::empty(), level: LogLevel::WARNING, timestamp: new DateTimeImmutable(), channel: 'test' ); expect($handler->isHandling($record))->toBeFalse(); }); it('returns self for fluent API', function () { $handler = new JsonFileHandler($this->testLogFile); $result = $handler->setMinLevel(LogLevel::ERROR); expect($result)->toBe($handler); }); }); describe('setIncludedFields()', function () { it('updates included fields', function () { $handler = new JsonFileHandler($this->testLogFile); $handler->setIncludedFields(['message']); $record = new LogRecord( message: 'Only message', context: LogContext::withData(['should_not_appear' => 'value']), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); expect(array_keys($json))->toBe(['message']); }); it('returns self for fluent API', function () { $handler = new JsonFileHandler($this->testLogFile); $result = $handler->setIncludedFields(['message']); expect($result)->toBe($handler); }); }); describe('setLogFile()', function () { it('updates log file path', function () { $handler = new JsonFileHandler($this->testLogFile); $newLogFile = $this->testDir . '/new-test.json'; $handler->setLogFile($newLogFile); $record = new LogRecord( message: 'New file test', context: LogContext::empty(), level: LogLevel::INFO, timestamp: new DateTimeImmutable(), channel: 'test' ); $handler->handle($record); expect(file_exists($newLogFile))->toBeTrue(); // Cleanup if (file_exists($newLogFile)) { unlink($newLogFile); } }); it('returns self for fluent API', function () { $handler = new JsonFileHandler($this->testLogFile); $result = $handler->setLogFile($this->testDir . '/other.json'); expect($result)->toBe($handler); }); }); describe('fluent interface', function () { it('supports chaining methods', function () { $handler = new JsonFileHandler($this->testLogFile); $handler ->setMinLevel(LogLevel::WARNING) ->setIncludedFields(['level', 'message']) // Neue einheitliche Feldnamen ->setLogFile($this->testLogFile); $record = new LogRecord( message: 'Chained config', context: LogContext::empty(), level: LogLevel::ERROR, timestamp: new DateTimeImmutable(), channel: 'test' ); expect($handler->isHandling($record))->toBeTrue(); $handler->handle($record); $content = file_get_contents($this->testLogFile); $json = json_decode(trim($content), true); expect($json)->toBe([ 'level' => 'ERROR', 'message' => 'Chained config', ]); }); }); });