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