- 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.
545 lines
17 KiB
PHP
545 lines
17 KiB
PHP
<?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');
|
|
});
|
|
});
|
|
});
|