feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,520 @@
<?php
declare(strict_types=1);
use App\Framework\Logging\Formatter\DevelopmentFormatter;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
describe('DevelopmentFormatter', function () {
beforeEach(function () {
$this->timestamp = new DateTimeImmutable('2024-01-15 10:30:45.123456', new DateTimeZone('Europe/Berlin'));
});
describe('constructor', function () {
it('accepts default configuration', function () {
$formatter = new DevelopmentFormatter();
expect($formatter instanceof DevelopmentFormatter)->toBeTrue();
});
it('accepts color output option', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
expect($formatter instanceof DevelopmentFormatter)->toBeTrue();
});
it('accepts include stack trace option', function () {
$formatter = new DevelopmentFormatter(includeStackTrace: false);
expect($formatter instanceof DevelopmentFormatter)->toBeTrue();
});
it('accepts both options', function () {
$formatter = new DevelopmentFormatter(
includeStackTrace: true,
colorOutput: true
);
expect($formatter instanceof DevelopmentFormatter)->toBeTrue();
});
});
describe('basic formatting', function () {
it('returns formatted string', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBeString();
});
it('includes timestamp with microseconds', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, '10:30:45.'))->toBeTrue();
});
it('includes log level', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::WARNING,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'WARNING'))->toBeTrue();
});
it('includes message', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Important test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'Important test message'))->toBeTrue();
});
it('includes channel when provided', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp,
channel: 'security'
);
$result = $formatter($record);
expect(str_contains($result, 'security'))->toBeTrue();
});
});
describe('color output', function () {
it('includes ANSI color codes when enabled', function () {
$formatter = new DevelopmentFormatter(colorOutput: true);
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
// Should contain ANSI escape sequences
expect(str_contains($result, "\033["))->toBeTrue();
});
it('excludes ANSI color codes when disabled', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
// Should not contain ANSI escape sequences
expect(str_contains($result, "\033["))->toBeFalse();
});
it('uses different colors for different log levels', function () {
$formatter = new DevelopmentFormatter(colorOutput: true);
$infoRecord = new LogRecord(
message: 'Info',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$errorRecord = new LogRecord(
message: 'Error',
context: LogContext::empty(),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$infoResult = $formatter($infoRecord);
$errorResult = $formatter($errorRecord);
// Different log levels should have different color codes
expect($infoResult !== $errorResult)->toBeTrue();
});
});
describe('context formatting', function () {
it('formats context data with indentation', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::withData([
'user_id' => 123,
'action' => 'login'
]),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'user_id'))->toBeTrue();
expect(str_contains($result, '123'))->toBeTrue();
expect(str_contains($result, 'action'))->toBeTrue();
expect(str_contains($result, 'login'))->toBeTrue();
});
it('handles nested context arrays', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::withData([
'user' => [
'id' => 123,
'name' => 'John Doe'
]
]),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
// Arrays are formatted as Array[n], names won't appear
expect(str_contains($result, 'user'))->toBeTrue();
expect(str_contains($result, 'Array'))->toBeTrue();
});
it('handles empty context gracefully', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBeString();
expect(str_contains($result, 'Test message'))->toBeTrue();
});
});
describe('extras formatting', function () {
it('formats extra data when present', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$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);
expect(str_contains($result, 'request_id'))->toBeTrue();
expect(str_contains($result, 'req-123'))->toBeTrue();
expect(str_contains($result, 'session_id'))->toBeTrue();
expect(str_contains($result, 'sess-456'))->toBeTrue();
});
it('handles empty extras gracefully', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBeString();
});
});
describe('exception formatting', function () {
it('formats exception from extras', function () {
$formatter = new DevelopmentFormatter(includeStackTrace: false, colorOutput: false);
$record = (new LogRecord(
message: 'Exception occurred',
context: LogContext::empty(),
level: LogLevel::ERROR,
timestamp: $this->timestamp
))->addExtra('exception_class', 'RuntimeException')
->addExtra('exception_message', 'Test exception message')
->addExtra('exception_file', '/path/to/file.php')
->addExtra('exception_line', 42);
$result = $formatter($record);
expect(str_contains($result, 'RuntimeException'))->toBeTrue();
expect(str_contains($result, '/path/to/file.php'))->toBeTrue();
});
it('includes stack trace when enabled', function () {
$formatter = new DevelopmentFormatter(includeStackTrace: true, colorOutput: false);
$record = (new LogRecord(
message: 'Exception occurred',
context: LogContext::empty(),
level: LogLevel::ERROR,
timestamp: $this->timestamp
))->addExtra('exception_class', 'RuntimeException')
->addExtra('exception_file', '/path/to/file.php')
->addExtra('exception_line', 42)
->addExtra('stack_trace_short', '#0 /path/to/caller.php(10)');
$result = $formatter($record);
expect(str_contains($result, 'RuntimeException'))->toBeTrue();
expect(str_contains($result, '#0'))->toBeTrue();
});
it('excludes stack trace when disabled', function () {
$formatter = new DevelopmentFormatter(includeStackTrace: false, colorOutput: false);
$record = (new LogRecord(
message: 'Exception occurred',
context: LogContext::empty(),
level: LogLevel::ERROR,
timestamp: $this->timestamp
))->addExtra('exception_class', 'RuntimeException')
->addExtra('exception_file', '/path/to/file.php')
->addExtra('exception_line', 42)
->addExtra('stack_trace_short', '#0 /path/to/caller.php(10)');
$result = $formatter($record);
expect(str_contains($result, 'RuntimeException'))->toBeTrue();
// Stack trace should not be included when disabled
$hasStackTrace = str_contains($result, 'Trace:');
expect($hasStackTrace)->toBeFalse();
});
});
describe('log levels', function () {
it('formats DEBUG level', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Debug message',
context: LogContext::empty(),
level: LogLevel::DEBUG,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'DEBUG'))->toBeTrue();
});
it('formats INFO level', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Info message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'INFO'))->toBeTrue();
});
it('formats WARNING level', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Warning message',
context: LogContext::empty(),
level: LogLevel::WARNING,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'WARNING'))->toBeTrue();
});
it('formats ERROR level', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Error message',
context: LogContext::empty(),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'ERROR'))->toBeTrue();
});
it('formats CRITICAL level', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Critical message',
context: LogContext::empty(),
level: LogLevel::CRITICAL,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'CRITICAL'))->toBeTrue();
});
it('formats EMERGENCY level', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Emergency message',
context: LogContext::empty(),
level: LogLevel::EMERGENCY,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'EMERGENCY'))->toBeTrue();
});
});
describe('readonly behavior', function () {
it('is a readonly class', function () {
$reflection = new ReflectionClass(DevelopmentFormatter::class);
expect($reflection->isReadOnly())->toBeTrue();
});
});
describe('edge cases', function () {
it('handles empty message', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: '',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBeString();
});
it('handles very long message', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$longMessage = str_repeat('x', 1000);
$record = new LogRecord(
message: $longMessage,
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'xxx'))->toBeTrue();
});
it('handles unicode characters', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Unicode: 你好 мир 🌍',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, '你好'))->toBeTrue();
expect(str_contains($result, 'мир'))->toBeTrue();
expect(str_contains($result, '🌍'))->toBeTrue();
});
it('handles null channel', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp,
channel: null
);
$result = $formatter($record);
expect($result)->toBeString();
expect(str_contains($result, 'Test message'))->toBeTrue();
});
it('handles special characters in message', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord(
message: 'Message with "quotes" and \'apostrophes\' and \backslashes',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect(str_contains($result, 'quotes'))->toBeTrue();
});
});
});

View File

@@ -0,0 +1,543 @@
<?php
declare(strict_types=1);
use App\Framework\Logging\Formatter\JsonFormatter;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
describe('JsonFormatter', function () {
beforeEach(function () {
$this->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');
});
});
});

View File

@@ -0,0 +1,544 @@
<?php
declare(strict_types=1);
use App\Framework\Logging\Formatter\LineFormatter;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
describe('LineFormatter', function () {
beforeEach(function () {
$this->timestamp = new DateTimeImmutable('2024-01-15 10:30:45', new DateTimeZone('Europe/Berlin'));
});
describe('constructor', function () {
it('accepts default format and timestamp format', function () {
$formatter = new LineFormatter();
expect($formatter instanceof LineFormatter)->toBeTrue();
});
it('accepts custom format', function () {
$formatter = new LineFormatter(
format: '{level}: {message}'
);
expect($formatter instanceof LineFormatter)->toBeTrue();
});
it('accepts custom timestamp format', function () {
$formatter = new LineFormatter(
format: '[{timestamp}] {message}',
timestampFormat: 'H:i:s'
);
expect($formatter instanceof LineFormatter)->toBeTrue();
});
});
describe('default format', function () {
it('formats with default format string', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBeString();
expect($result)->toContain('2024-01-15');
expect($result)->toContain('INFO');
expect($result)->toContain('Test message');
});
it('includes channel in output', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp,
channel: 'security'
);
$result = $formatter($record);
expect($result)->toContain('security');
});
it('uses default channel "app" when none provided', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toContain('app');
});
it('includes context as JSON', function () {
$formatter = new LineFormatter();
$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)->toContain('"user_id":123');
expect($result)->toContain('"action":"login"');
});
it('handles empty context', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
message: 'Test message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
// Empty context should result in empty context string
expect($result)->toBeString();
expect($result)->toContain('Test message');
});
});
describe('custom format', function () {
it('uses custom format string', function () {
$formatter = new LineFormatter(
format: '{level}: {message}'
);
$record = new LogRecord(
message: 'Custom format',
context: LogContext::empty(),
level: LogLevel::WARNING,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBe('WARNING: Custom format');
});
it('supports timestamp-only format', function () {
$formatter = new LineFormatter(
format: '[{timestamp}]',
timestampFormat: 'Y-m-d H:i:s'
);
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBe('[2024-01-15 10:30:45]');
});
it('supports message-only format', function () {
$formatter = new LineFormatter(
format: '{message}'
);
$record = new LogRecord(
message: 'Simple message',
context: LogContext::empty(),
level: LogLevel::DEBUG,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBe('Simple message');
});
it('supports complex custom format', function () {
$formatter = new LineFormatter(
format: '[{timestamp}] {channel}.{level}: {message} {context}'
);
$record = new LogRecord(
message: 'Complex',
context: LogContext::withData(['key' => 'value']),
level: LogLevel::ERROR,
timestamp: $this->timestamp,
channel: 'api'
);
$result = $formatter($record);
expect($result)->toContain('[2024-01-15');
expect($result)->toContain('api.ERROR');
expect($result)->toContain('Complex');
expect($result)->toContain('"key":"value"');
});
});
describe('timestamp formatting', function () {
it('uses default timestamp format', function () {
$formatter = new LineFormatter(
format: '{timestamp}'
);
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
// Default format is Y-m-d H:i:s
expect($result)->toBe('2024-01-15 10:30:45');
});
it('uses custom timestamp format', function () {
$formatter = new LineFormatter(
format: '{timestamp}',
timestampFormat: 'd/m/Y H:i'
);
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBe('15/01/2024 10:30');
});
it('supports ISO 8601 timestamp format', function () {
$formatter = new LineFormatter(
format: '{timestamp}',
timestampFormat: 'c'
);
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toContain('2024-01-15T10:30:45');
});
it('supports microseconds in timestamp', function () {
$formatter = new LineFormatter(
format: '{timestamp}',
timestampFormat: 'Y-m-d H:i:s.u'
);
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toContain('2024-01-15 10:30:45.');
});
});
describe('context handling', function () {
it('formats context as JSON with unescaped slashes', function () {
$formatter = new LineFormatter(
format: '{context}'
);
$record = new LogRecord(
message: 'Message',
context: LogContext::withData([
'url' => 'https://example.com/path',
'file' => '/var/www/html/index.php'
]),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
// JSON_UNESCAPED_SLASHES should be used
expect($result)->toContain('https://example.com/path');
expect($result)->toContain('/var/www/html/index.php');
// Slashes should not be escaped
expect(str_contains($result, '\\/'))->toBeFalse();
});
it('combines context and extras', function () {
$formatter = new LineFormatter(
format: '{context}'
);
$record = (new LogRecord(
message: 'Message',
context: LogContext::withData(['context_key' => 'context_value']),
level: LogLevel::INFO,
timestamp: $this->timestamp
))->addExtra('extra_key', 'extra_value');
$result = $formatter($record);
expect($result)->toContain('"context_key":"context_value"');
expect($result)->toContain('"extra_key":"extra_value"');
});
it('handles nested context arrays', function () {
$formatter = new LineFormatter(
format: '{context}'
);
$record = new LogRecord(
message: 'Message',
context: LogContext::withData([
'user' => [
'id' => 123,
'name' => 'John Doe',
'roles' => ['admin', 'user']
]
]),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toContain('"user"');
expect($result)->toContain('"id":123');
expect($result)->toContain('"name":"John Doe"');
expect($result)->toContain('["admin","user"]');
});
it('handles special characters in context', function () {
$formatter = new LineFormatter(
format: '{context}'
);
$record = new LogRecord(
message: 'Message',
context: LogContext::withData([
'text' => 'Special chars: "quotes", \'apostrophes\', \\backslashes'
]),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toBeString();
expect($result)->toContain('Special chars');
});
});
describe('log levels', function () {
it('formats DEBUG level', function () {
$formatter = new LineFormatter(format: '{level}');
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::DEBUG,
timestamp: $this->timestamp
);
expect($formatter($record))->toBe('DEBUG');
});
it('formats INFO level', function () {
$formatter = new LineFormatter(format: '{level}');
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
expect($formatter($record))->toBe('INFO');
});
it('formats WARNING level', function () {
$formatter = new LineFormatter(format: '{level}');
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::WARNING,
timestamp: $this->timestamp
);
expect($formatter($record))->toBe('WARNING');
});
it('formats ERROR level', function () {
$formatter = new LineFormatter(format: '{level}');
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
expect($formatter($record))->toBe('ERROR');
});
it('formats CRITICAL level', function () {
$formatter = new LineFormatter(format: '{level}');
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::CRITICAL,
timestamp: $this->timestamp
);
expect($formatter($record))->toBe('CRITICAL');
});
it('formats ALERT level', function () {
$formatter = new LineFormatter(format: '{level}');
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::ALERT,
timestamp: $this->timestamp
);
expect($formatter($record))->toBe('ALERT');
});
it('formats EMERGENCY level', function () {
$formatter = new LineFormatter(format: '{level}');
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::EMERGENCY,
timestamp: $this->timestamp
);
expect($formatter($record))->toBe('EMERGENCY');
});
});
describe('readonly behavior', function () {
it('is a readonly class', function () {
$reflection = new ReflectionClass(LineFormatter::class);
expect($reflection->isReadOnly())->toBeTrue();
});
});
describe('edge cases', function () {
it('handles empty message', function () {
$formatter = new LineFormatter(format: '{message}');
$record = new LogRecord(
message: '',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
expect($formatter($record))->toBe('');
});
it('handles very long message', function () {
$formatter = new LineFormatter(format: '{message}');
$longMessage = str_repeat('x', 10000);
$record = new LogRecord(
message: $longMessage,
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toHaveLength(10000);
});
it('handles unicode characters in message', function () {
$formatter = new LineFormatter(format: '{message}');
$record = new LogRecord(
message: 'Unicode: 你好 мир 🌍',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $formatter($record);
expect($result)->toContain('你好');
expect($result)->toContain('мир');
expect($result)->toContain('🌍');
});
it('handles null channel gracefully', function () {
$formatter = new LineFormatter(format: '{channel}');
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp,
channel: null
);
$result = $formatter($record);
expect($result)->toBe('app'); // Default channel
});
it('handles format with no placeholders', function () {
$formatter = new LineFormatter(format: 'static text');
$record = new LogRecord(
message: 'Message',
context: LogContext::empty(),
level: LogLevel::INFO,
timestamp: $this->timestamp
);
expect($formatter($record))->toBe('static text');
});
});
});

View File

@@ -0,0 +1,354 @@
<?php
declare(strict_types=1);
use App\Framework\Logging\Formatter\LineFormatter;
use App\Framework\Logging\LogChannel;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
describe('LineFormatter', function () {
describe('default format', function () {
it('formats basic log record with default format', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Test message',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain('app');
expect($formatted)->toContain('INFO');
expect($formatted)->toContain('Test message');
});
it('includes timestamp in formatted output', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Test message',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
// Check for timestamp pattern (YYYY-MM-DD HH:MM:SS)
expect($formatted)->toMatch('/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/');
});
it('includes channel in formatted output', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Test message',
channel: new LogChannel('security')
);
$formatted = $formatter($record);
expect($formatted)->toContain('security');
});
it('includes level name in formatted output', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::ERROR,
message: 'Error message',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain('ERROR');
});
it('includes message in formatted output', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Important information',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain('Important information');
});
it('defaults to "app" channel when no channel specified', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Test message',
channel: null
);
$formatted = $formatter($record);
expect($formatted)->toContain('app');
});
});
describe('custom format', function () {
it('uses custom format string', function () {
$formatter = new LineFormatter(format: '{level}: {message}');
$record = new LogRecord(
level: LogLevel::WARNING,
message: 'Custom format',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toBe('WARNING: Custom format');
});
it('allows custom timestamp format', function () {
$formatter = new LineFormatter(timestampFormat: 'Y-m-d');
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Test message',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
// Should match YYYY-MM-DD format without time
expect($formatted)->toMatch('/\d{4}-\d{2}-\d{2}/');
expect($formatted)->not->toMatch('/\d{2}:\d{2}:\d{2}/');
});
it('supports minimal format', function () {
$formatter = new LineFormatter(format: '{message}');
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Just the message',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toBe('Just the message');
});
});
describe('context handling', function () {
it('includes context data as JSON when present', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'User action',
channel: new LogChannel('app'),
context: ['user_id' => 123, 'action' => 'login']
);
$formatted = $formatter($record);
expect($formatted)->toContain('user_id');
expect($formatted)->toContain('123');
expect($formatted)->toContain('action');
expect($formatted)->toContain('login');
});
it('includes extra data as JSON when present', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Test message',
channel: new LogChannel('app'),
extra: ['request_id' => 'req-123']
);
$formatted = $formatter($record);
expect($formatted)->toContain('request_id');
expect($formatted)->toContain('req-123');
});
it('merges context and extra data in JSON', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Test message',
channel: new LogChannel('app'),
context: ['key1' => 'value1'],
extra: ['key2' => 'value2']
);
$formatted = $formatter($record);
expect($formatted)->toContain('key1');
expect($formatted)->toContain('value1');
expect($formatted)->toContain('key2');
expect($formatted)->toContain('value2');
});
it('shows empty string when no context or extra data', function () {
$formatter = new LineFormatter(format: '{message} {context}');
$record = new LogRecord(
level: LogLevel::INFO,
message: 'No context',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toBe('No context ');
});
});
describe('different log levels', function () {
it('formats DEBUG level correctly', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::DEBUG,
message: 'Debug info',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain('DEBUG');
});
it('formats NOTICE level correctly', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::NOTICE,
message: 'Notice',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain('NOTICE');
});
it('formats CRITICAL level correctly', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::CRITICAL,
message: 'Critical error',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain('CRITICAL');
});
it('formats EMERGENCY level correctly', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::EMERGENCY,
message: 'Emergency',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain('EMERGENCY');
});
});
describe('special characters', function () {
it('handles unicode characters in message', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Übung mit Ümläüten und 日本語',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain('Übung mit Ümläüten und 日本語');
});
it('handles newlines in message', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: "Line 1\nLine 2",
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain("Line 1\nLine 2");
});
it('handles quotes in message', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Message with "quotes" and \'apostrophes\'',
channel: new LogChannel('app')
);
$formatted = $formatter($record);
expect($formatted)->toContain('Message with "quotes" and \'apostrophes\'');
});
});
describe('complex scenarios', function () {
it('formats record with all fields', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::ERROR,
message: 'Database error',
channel: new LogChannel('database'),
context: ['query' => 'SELECT * FROM users'],
extra: ['request_id' => 'req-456']
);
$formatted = $formatter($record);
expect($formatted)->toContain('ERROR');
expect($formatted)->toContain('Database error');
expect($formatted)->toContain('database');
expect($formatted)->toContain('query');
expect($formatted)->toContain('request_id');
});
it('is invokable', function () {
$formatter = new LineFormatter();
$record = new LogRecord(
level: LogLevel::INFO,
message: 'Invokable test',
channel: new LogChannel('app')
);
// Test that formatter is invokable
$formatted = $formatter($record);
expect($formatted)->toBeString();
expect($formatted)->toContain('Invokable test');
});
});
});

View File

@@ -84,7 +84,7 @@ describe('JsonFormatter', function () {
$output = $formatter($record);
$decoded = json_decode($output, true);
expect($decoded)->not->toHaveKey('extra');
expect(isset($decoded['extra']))->toBeFalse();
});
});
@@ -109,7 +109,7 @@ describe('DevelopmentFormatter', function () {
$record = new LogRecord('Exception occurred', $context, LogLevel::ERROR, $this->timestamp);
// Simulate exception processor enrichment
$record->addExtras([
$record = $record->withExtras([
'exception_class' => Exception::class,
'exception_file' => __FILE__,
'exception_line' => __LINE__,
@@ -196,6 +196,7 @@ describe('Formatter Integration', function () {
expect($structuredOutput)->toContain('Multi-formatter test');
// JSON should be valid
expect(json_decode($jsonOutput))->not->toBeNull();
$jsonDecoded = json_decode($jsonOutput);
expect($jsonDecoded !== null)->toBeTrue();
});
});

View File

@@ -0,0 +1,531 @@
<?php
declare(strict_types=1);
use App\Framework\Logging\Formatter\StructuredFormatter;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
describe('StructuredFormatter', function () {
beforeEach(function () {
$this->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();
});
});
});