docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\LogChannel;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ProcessorManager;
use PHPUnit\Framework\TestCase;
final class ChannelSystemTest extends TestCase
{
private array $capturedRecords = [];
private DefaultLogger $logger;
protected function setUp(): void
{
$this->capturedRecords = [];
// Test-Handler der alle Records captured
$testHandler = new class ($this->capturedRecords) implements LogHandler {
public function __construct(private array &$records)
{
}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->records[] = $record;
}
};
$this->logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$testHandler],
processorManager: new ProcessorManager()
);
}
public function test_channel_system_basic_functionality(): void
{
// Teste alle Channel-Logger
$this->logger->security->warning('Security alert', ['ip' => '192.168.1.1']);
$this->logger->cache->debug('Cache miss', ['key' => 'user_123']);
$this->logger->database->error('Query failed', ['table' => 'users']);
$this->logger->framework->info('Route registered', ['path' => '/api/test']);
$this->logger->error->critical('System failure', ['component' => 'auth']);
expect($this->capturedRecords)->toHaveCount(5);
// Prüfe Channels
expect($this->capturedRecords[0]->getChannel())->toBe('security');
expect($this->capturedRecords[1]->getChannel())->toBe('cache');
expect($this->capturedRecords[2]->getChannel())->toBe('database');
expect($this->capturedRecords[3]->getChannel())->toBe('framework');
expect($this->capturedRecords[4]->getChannel())->toBe('error');
// Prüfe Messages
expect($this->capturedRecords[0]->getMessage())->toBe('Security alert');
expect($this->capturedRecords[1]->getMessage())->toBe('Cache miss');
expect($this->capturedRecords[2]->getMessage())->toBe('Query failed');
expect($this->capturedRecords[3]->getMessage())->toBe('Route registered');
expect($this->capturedRecords[4]->getMessage())->toBe('System failure');
// Prüfe Levels
expect($this->capturedRecords[0]->getLevel())->toBe(LogLevel::WARNING);
expect($this->capturedRecords[1]->getLevel())->toBe(LogLevel::DEBUG);
expect($this->capturedRecords[2]->getLevel())->toBe(LogLevel::ERROR);
expect($this->capturedRecords[3]->getLevel())->toBe(LogLevel::INFO);
expect($this->capturedRecords[4]->getLevel())->toBe(LogLevel::CRITICAL);
}
public function test_standard_logging_vs_channel_logging(): void
{
// Standard-Logging
$this->logger->info('Standard log');
// Channel-Logging
$this->logger->security->info('Channel log');
expect($this->capturedRecords)->toHaveCount(2);
// Standard-Log hat keinen Channel
expect($this->capturedRecords[0]->getChannel())->toBeNull();
expect($this->capturedRecords[0]->getMessage())->toBe('Standard log');
// Channel-Log hat Channel
expect($this->capturedRecords[1]->getChannel())->toBe('security');
expect($this->capturedRecords[1]->getMessage())->toBe('Channel log');
}
public function test_all_log_levels_work_with_channels(): void
{
$this->logger->security->debug('Debug message');
$this->logger->security->info('Info message');
$this->logger->security->notice('Notice message');
$this->logger->security->warning('Warning message');
$this->logger->security->error('Error message');
$this->logger->security->critical('Critical message');
$this->logger->security->alert('Alert message');
$this->logger->security->emergency('Emergency message');
expect($this->capturedRecords)->toHaveCount(8);
$expectedLevels = [
LogLevel::DEBUG,
LogLevel::INFO,
LogLevel::NOTICE,
LogLevel::WARNING,
LogLevel::ERROR,
LogLevel::CRITICAL,
LogLevel::ALERT,
LogLevel::EMERGENCY,
];
foreach ($this->capturedRecords as $index => $record) {
expect($record->getChannel())->toBe('security');
expect($record->getLevel())->toBe($expectedLevels[$index]);
}
}
public function test_context_data_passed_correctly(): void
{
$context = [
'user_id' => 123,
'action' => 'login',
'metadata' => ['ip' => '127.0.0.1', 'browser' => 'Chrome'],
];
$this->logger->security->warning('Authentication event', $context);
expect($this->capturedRecords)->toHaveCount(1);
expect($this->capturedRecords[0]->getContext())->toBe($context);
expect($this->capturedRecords[0]->getChannel())->toBe('security');
}
public function test_channel_isolation(): void
{
// Jeder Channel sollte unabhängig funktionieren
$this->logger->security->error('Security error');
$this->logger->cache->error('Cache error');
$this->logger->database->error('Database error');
expect($this->capturedRecords)->toHaveCount(3);
// Alle sollten ERROR level haben, aber verschiedene Channels
foreach ($this->capturedRecords as $record) {
expect($record->getLevel())->toBe(LogLevel::ERROR);
}
expect($this->capturedRecords[0]->getChannel())->toBe('security');
expect($this->capturedRecords[1]->getChannel())->toBe('cache');
expect($this->capturedRecords[2]->getChannel())->toBe('database');
expect($this->capturedRecords[0]->getMessage())->toBe('Security error');
expect($this->capturedRecords[1]->getMessage())->toBe('Cache error');
expect($this->capturedRecords[2]->getMessage())->toBe('Database error');
}
public function test_channel_enum_integration(): void
{
// Teste dass die Channels den Enum-Werten entsprechen
expect($this->logger->security->getChannel())->toBe(LogChannel::SECURITY);
expect($this->logger->cache->getChannel())->toBe(LogChannel::CACHE);
expect($this->logger->database->getChannel())->toBe(LogChannel::DATABASE);
expect($this->logger->framework->getChannel())->toBe(LogChannel::FRAMEWORK);
expect($this->logger->error->getChannel())->toBe(LogChannel::ERROR);
// Teste dass die Channel-Namen korrekt sind
expect(LogChannel::SECURITY->value)->toBe('security');
expect(LogChannel::CACHE->value)->toBe('cache');
expect(LogChannel::DATABASE->value)->toBe('database');
expect(LogChannel::FRAMEWORK->value)->toBe('framework');
expect(LogChannel::ERROR->value)->toBe('error');
}
public function test_realistic_usage_scenario(): void
{
// Simuliere eine realistische Anwendungssequenz
// 1. Framework startet
$this->logger->framework->info('Application starting');
// 2. User versucht Login
$this->logger->security->info('Login attempt', ['email' => 'user@test.com']);
// 3. Cache Miss
$this->logger->cache->debug('User cache miss', ['email' => 'user@test.com']);
// 4. Database Query
$this->logger->database->debug('User lookup query', ['table' => 'users']);
// 5. Successful authentication
$this->logger->security->info('Login successful', ['user_id' => 42]);
// 6. Cache Store
$this->logger->cache->info('User cached', ['user_id' => 42, 'ttl' => 3600]);
// 7. Später: Ein Fehler
$this->logger->database->error('Connection timeout', ['host' => 'db.example.com']);
$this->logger->error->critical('Service degraded', ['affected' => ['users', 'orders']]);
expect($this->capturedRecords)->toHaveCount(8);
// Prüfe die Sequenz
$channels = array_map(fn ($record) => $record->getChannel(), $this->capturedRecords);
$expected = ['framework', 'security', 'cache', 'database', 'security', 'cache', 'database', 'error'];
expect($channels)->toBe($expected);
// Prüfe dass jeder Record den richtigen Channel hat
foreach ($this->capturedRecords as $index => $record) {
expect($record->getChannel())->toBe($expected[$index]);
}
}
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging;
use App\Framework\Logging\ChannelLogger;
use App\Framework\Logging\DefaultChannelLogger;
use App\Framework\Logging\LogChannel;
use App\Framework\Logging\Logger;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ValueObjects\LogContext;
use PHPUnit\Framework\TestCase;
final class DefaultChannelLoggerTest extends TestCase
{
private Logger $mockLogger;
private ChannelLogger $channelLogger;
protected function setUp(): void
{
$this->mockLogger = $this->createMock(Logger::class);
$this->channelLogger = new DefaultChannelLogger($this->mockLogger, LogChannel::SECURITY);
}
public function test_implements_channel_logger_interface(): void
{
expect($this->channelLogger)->toBeInstanceOf(ChannelLogger::class);
}
public function test_can_get_channel(): void
{
expect($this->channelLogger->getChannel())->toBe(LogChannel::SECURITY);
}
public function test_debug_delegates_to_parent_logger(): void
{
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::DEBUG, 'Test message', ['key' => 'value']);
$this->channelLogger->debug('Test message', ['key' => 'value']);
}
public function test_info_delegates_to_parent_logger(): void
{
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::INFO, 'Test message', ['key' => 'value']);
$this->channelLogger->info('Test message', ['key' => 'value']);
}
public function test_warning_delegates_to_parent_logger(): void
{
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::WARNING, 'Test message', ['key' => 'value']);
$this->channelLogger->warning('Test message', ['key' => 'value']);
}
public function test_error_delegates_to_parent_logger(): void
{
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::ERROR, 'Test message', ['key' => 'value']);
$this->channelLogger->error('Test message', ['key' => 'value']);
}
public function test_critical_delegates_to_parent_logger(): void
{
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::CRITICAL, 'Test message', ['key' => 'value']);
$this->channelLogger->critical('Test message', ['key' => 'value']);
}
public function test_alert_delegates_to_parent_logger(): void
{
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::ALERT, 'Test message', ['key' => 'value']);
$this->channelLogger->alert('Test message', ['key' => 'value']);
}
public function test_emergency_delegates_to_parent_logger(): void
{
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::EMERGENCY, 'Test message', ['key' => 'value']);
$this->channelLogger->emergency('Test message', ['key' => 'value']);
}
public function test_log_delegates_to_parent_logger(): void
{
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::WARNING, 'Test message', ['key' => 'value']);
$this->channelLogger->log(LogLevel::WARNING, 'Test message', ['key' => 'value']);
}
public function test_supports_log_context_objects(): void
{
$logContext = LogContext::withData(['user_id' => 123]);
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::INFO, 'Test message', $logContext);
$this->channelLogger->info('Test message', $logContext);
}
public function test_supports_empty_context(): void
{
$this->mockLogger
->expects($this->once())
->method('logToChannel')
->with(LogChannel::SECURITY, LogLevel::INFO, 'Test message', []);
$this->channelLogger->info('Test message');
}
public function test_different_channels_work_independently(): void
{
$cacheLogger = new DefaultChannelLogger($this->mockLogger, LogChannel::CACHE);
$dbLogger = new DefaultChannelLogger($this->mockLogger, LogChannel::DATABASE);
expect($cacheLogger->getChannel())->toBe(LogChannel::CACHE);
expect($dbLogger->getChannel())->toBe(LogChannel::DATABASE);
expect($this->channelLogger->getChannel())->toBe(LogChannel::SECURITY);
}
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging;
use App\Framework\Logging\ChannelLogger;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\LogChannel;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ProcessorManager;
use PHPUnit\Framework\TestCase;
final class DefaultLoggerChannelTest extends TestCase
{
private array $capturedRecords = [];
private DefaultLogger $logger;
protected function setUp(): void
{
$this->capturedRecords = [];
// Mock Handler der alle Records captured
$mockHandler = $this->createMock(LogHandler::class);
$mockHandler->method('isHandling')->willReturn(true);
$mockHandler->method('handle')->willReturnCallback(function (LogRecord $record) {
$this->capturedRecords[] = $record;
});
$this->logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$mockHandler],
processorManager: new ProcessorManager()
);
}
public function test_logger_has_all_channel_loggers(): void
{
expect($this->logger->security)->toBeInstanceOf(ChannelLogger::class);
expect($this->logger->cache)->toBeInstanceOf(ChannelLogger::class);
expect($this->logger->database)->toBeInstanceOf(ChannelLogger::class);
expect($this->logger->framework)->toBeInstanceOf(ChannelLogger::class);
expect($this->logger->error)->toBeInstanceOf(ChannelLogger::class);
}
public function test_channel_loggers_have_correct_channels(): void
{
expect($this->logger->security->getChannel())->toBe(LogChannel::SECURITY);
expect($this->logger->cache->getChannel())->toBe(LogChannel::CACHE);
expect($this->logger->database->getChannel())->toBe(LogChannel::DATABASE);
expect($this->logger->framework->getChannel())->toBe(LogChannel::FRAMEWORK);
expect($this->logger->error->getChannel())->toBe(LogChannel::ERROR);
}
public function test_log_to_channel_creates_record_with_channel(): void
{
$this->logger->logToChannel(LogChannel::SECURITY, LogLevel::WARNING, 'Test message', ['key' => 'value']);
expect($this->capturedRecords)->toHaveCount(1);
$record = $this->capturedRecords[0];
expect($record->getChannel())->toBe('security');
expect($record->getMessage())->toBe('Test message');
expect($record->getLevel())->toBe(LogLevel::WARNING);
expect($record->getContext())->toBe(['key' => 'value']);
}
public function test_channel_logger_creates_records_with_correct_channel(): void
{
$this->logger->security->error('Security alert', ['ip' => '192.168.1.1']);
expect($this->capturedRecords)->toHaveCount(1);
$record = $this->capturedRecords[0];
expect($record->getChannel())->toBe('security');
expect($record->getMessage())->toBe('Security alert');
expect($record->getLevel())->toBe(LogLevel::ERROR);
expect($record->getContext())->toBe(['ip' => '192.168.1.1']);
}
public function test_different_channels_create_different_records(): void
{
$this->logger->security->warning('Security warning');
$this->logger->cache->debug('Cache debug');
$this->logger->database->error('DB error');
expect($this->capturedRecords)->toHaveCount(3);
expect($this->capturedRecords[0]->getChannel())->toBe('security');
expect($this->capturedRecords[0]->getLevel())->toBe(LogLevel::WARNING);
expect($this->capturedRecords[1]->getChannel())->toBe('cache');
expect($this->capturedRecords[1]->getLevel())->toBe(LogLevel::DEBUG);
expect($this->capturedRecords[2]->getChannel())->toBe('database');
expect($this->capturedRecords[2]->getLevel())->toBe(LogLevel::ERROR);
}
public function test_standard_logging_still_works(): void
{
$this->logger->info('Standard log message');
expect($this->capturedRecords)->toHaveCount(1);
$record = $this->capturedRecords[0];
expect($record->getChannel())->toBeNull(); // Standard logs haben keinen Channel
expect($record->getMessage())->toBe('Standard log message');
expect($record->getLevel())->toBe(LogLevel::INFO);
}
public function test_log_to_channel_respects_min_level(): void
{
// Logger mit höherem Min-Level erstellen
$logger = new DefaultLogger(minLevel: LogLevel::ERROR);
// Debug-Level sollte ignoriert werden
$logger->logToChannel(LogChannel::SECURITY, LogLevel::DEBUG, 'Debug message');
// Keine Records sollten erstellt werden, da kein Handler gesetzt ist
// aber der Level-Check sollte funktionieren
expect(true)->toBeTrue(); // Test dass keine Exception geworfen wird
}
public function test_log_to_channel_with_empty_context(): void
{
$this->logger->logToChannel(LogChannel::FRAMEWORK, LogLevel::INFO, 'Framework message');
expect($this->capturedRecords)->toHaveCount(1);
$record = $this->capturedRecords[0];
expect($record->getChannel())->toBe('framework');
expect($record->getContext())->toBe([]);
}
public function test_mixed_logging_works_correctly(): void
{
// Mix aus Standard- und Channel-Logging
$this->logger->info('Standard info');
$this->logger->security->warning('Security warning');
$this->logger->error('Standard error');
$this->logger->cache->debug('Cache debug');
expect($this->capturedRecords)->toHaveCount(4);
// Standard logs haben keinen Channel
expect($this->capturedRecords[0]->getChannel())->toBeNull();
expect($this->capturedRecords[2]->getChannel())->toBeNull();
// Channel logs haben korrekte Channels
expect($this->capturedRecords[1]->getChannel())->toBe('security');
expect($this->capturedRecords[3]->getChannel())->toBe('cache');
}
}

View File

@@ -0,0 +1,403 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\LogChannel;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
beforeEach(function () {
// Reset any static state
});
it('can create a logger with default settings', function () {
$logger = new DefaultLogger();
expect($logger)->toBeInstanceOf(DefaultLogger::class);
expect($logger->getConfiguration()['minLevel'])->toBe(LogLevel::DEBUG->value);
});
it('can create a logger with custom minimum level', function () {
$logger = new DefaultLogger(minLevel: LogLevel::WARNING);
expect($logger->getConfiguration()['minLevel'])->toBe(LogLevel::WARNING->value);
});
it('respects minimum log level and filters lower priority messages', function () {
$mockHandler = new class () implements LogHandler {
public array $handledRecords = [];
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->handledRecords[] = $record;
}
};
$logger = new DefaultLogger(
minLevel: LogLevel::WARNING,
handlers: [$mockHandler]
);
// These should be ignored (lower than WARNING)
$logger->debug('Debug message');
$logger->info('Info message');
$logger->notice('Notice message');
// These should be handled (WARNING or higher)
$logger->warning('Warning message');
$logger->error('Error message');
$logger->critical('Critical message');
expect($mockHandler->handledRecords)->toHaveCount(3);
expect($mockHandler->handledRecords[0]->getMessage())->toBe('Warning message');
expect($mockHandler->handledRecords[1]->getMessage())->toBe('Error message');
expect($mockHandler->handledRecords[2]->getMessage())->toBe('Critical message');
});
it('can log messages with all severity levels', function () {
$recordedLevels = [];
$mockHandler = new class ($recordedLevels) implements LogHandler {
public function __construct(private array &$recordedLevels)
{
}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->recordedLevels[] = $record->getLevel()->value;
}
};
$logger = new DefaultLogger(handlers: [$mockHandler]);
$logger->debug('Debug');
$logger->info('Info');
$logger->notice('Notice');
$logger->warning('Warning');
$logger->error('Error');
$logger->critical('Critical');
$logger->alert('Alert');
$logger->emergency('Emergency');
expect($recordedLevels)->toBe([
LogLevel::DEBUG->value,
LogLevel::INFO->value,
LogLevel::NOTICE->value,
LogLevel::WARNING->value,
LogLevel::ERROR->value,
LogLevel::CRITICAL->value,
LogLevel::ALERT->value,
LogLevel::EMERGENCY->value,
]);
});
it('can log with LogContext', function () {
$capturedContext = null;
$mockHandler = new class ($capturedContext) implements LogHandler {
public function __construct(private ?array &$capturedContext)
{
}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->capturedContext = $record->getContext();
}
};
$logger = new DefaultLogger(handlers: [$mockHandler]);
$context = LogContext::withData(['user_id' => 123, 'action' => 'login']);
$logger->info('User action', $context);
expect($capturedContext)->toMatchArray(['user_id' => 123, 'action' => 'login']);
});
it('can log with null context', function () {
$capturedContext = null;
$mockHandler = new class ($capturedContext) implements LogHandler {
public function __construct(private ?array &$capturedContext)
{
}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->capturedContext = $record->getContext();
}
};
$logger = new DefaultLogger(handlers: [$mockHandler]);
// Log without context
$logger->info('Simple message');
expect($capturedContext)->toBe([]);
});
it('can be created without ProcessorManager', function () {
$processedRecord = null;
$mockHandler = new class ($processedRecord) implements LogHandler {
public function __construct(private ?LogRecord &$processedRecord)
{
}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->processedRecord = $record;
}
};
$logger = new DefaultLogger(
handlers: [$mockHandler]
);
$logger->info('Test message');
expect($processedRecord)->not->toBeNull();
expect($processedRecord->getMessage())->toBe('Test message');
});
it('logs with LogContext including tags', function () {
$capturedContext = null;
$mockHandler = new class ($capturedContext) implements LogHandler {
public function __construct(private ?array &$capturedContext)
{
}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->capturedContext = $record->getContext();
}
};
$logger = new DefaultLogger(
handlers: [$mockHandler]
);
$context = LogContext::withData(['request_id' => 'req-123'])
->addTags('api', 'v2');
$logger->info('API request', $context);
expect($capturedContext)->toMatchArray([
'request_id' => 'req-123',
'_tags' => ['api', 'v2'],
]);
});
it('provides channel loggers for different log channels', function () {
$logger = new DefaultLogger();
expect($logger->security)->not->toBeNull();
expect($logger->cache)->not->toBeNull();
expect($logger->database)->not->toBeNull();
expect($logger->framework)->not->toBeNull();
expect($logger->error)->not->toBeNull();
});
it('can log to specific channels using channel loggers', function () {
$capturedChannel = null;
$capturedMessage = null;
$mockHandler = new class ($capturedChannel, $capturedMessage) implements LogHandler {
public function __construct(
private ?string &$capturedChannel,
private ?string &$capturedMessage
) {
}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->capturedChannel = $record->getChannel();
$this->capturedMessage = $record->getMessage();
}
};
$logger = new DefaultLogger(handlers: [$mockHandler]);
// Log to security channel with LogContext
$context = LogContext::withData(['ip' => '192.168.1.1'])->addTags('security');
$logger->security->warning('Unauthorized access attempt', $context);
expect($capturedChannel)->toBe(LogChannel::SECURITY->value);
expect($capturedMessage)->toBe('Unauthorized access attempt');
// Log to database channel without context
$logger->database->error('Connection failed');
expect($capturedChannel)->toBe(LogChannel::DATABASE->value);
expect($capturedMessage)->toBe('Connection failed');
});
it('calls multiple handlers when configured', function () {
$handler1Called = false;
$handler2Called = false;
$handler3Called = false;
$handler1 = new class ($handler1Called) implements LogHandler {
public function __construct(private bool &$called)
{
}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->called = true;
}
};
$handler2 = new class ($handler2Called) implements LogHandler {
public function __construct(private bool &$called)
{
}
public function isHandling(LogRecord $record): bool
{
return $record->getLevel()->value >= LogLevel::WARNING->value;
}
public function handle(LogRecord $record): void
{
$this->called = true;
}
};
$handler3 = new class ($handler3Called) implements LogHandler {
public function __construct(private bool &$called)
{
}
public function isHandling(LogRecord $record): bool
{
return false; // Never handles
}
public function handle(LogRecord $record): void
{
$this->called = true;
}
};
$logger = new DefaultLogger(handlers: [$handler1, $handler2, $handler3]);
$logger->error('Test error');
expect($handler1Called)->toBeTrue();
expect($handler2Called)->toBeTrue();
expect($handler3Called)->toBeFalse();
});
it('creates log records with correct timestamp', function () {
$capturedRecord = null;
$mockHandler = new class ($capturedRecord) implements LogHandler {
public function __construct(private ?LogRecord &$capturedRecord)
{
}
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->capturedRecord = $record;
}
};
$logger = new DefaultLogger(handlers: [$mockHandler]);
$beforeLog = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin'));
$logger->info('Test timestamp');
$afterLog = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin'));
expect($capturedRecord)->not->toBeNull();
expect($capturedRecord->getTimestamp())->toBeInstanceOf(\DateTimeImmutable::class);
$timestamp = $capturedRecord->getTimestamp();
expect($timestamp >= $beforeLog)->toBeTrue();
expect($timestamp <= $afterLog)->toBeTrue();
expect($timestamp->getTimezone()->getName())->toBe('Europe/Berlin');
});
it('returns correct configuration information', function () {
$handler1 = new class () implements LogHandler {
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
}
};
$handler2 = new class () implements LogHandler {
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
}
};
$logger = new DefaultLogger(
minLevel: LogLevel::INFO,
handlers: [$handler1, $handler2]
);
$config = $logger->getConfiguration();
expect($config['minLevel'])->toBe(LogLevel::INFO->value);
expect($config['handlers'])->toHaveCount(2);
expect($config['processors'])->toBeArray();
});

View File

@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging\Formatter;
use App\Framework\Logging\Formatter\DevelopmentFormatter;
use App\Framework\Logging\Formatter\JsonFormatter;
use App\Framework\Logging\Formatter\LineFormatter;
use App\Framework\Logging\Formatter\StructuredFormatter;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
use DateTimeImmutable;
use DateTimeZone;
use Exception;
beforeEach(function () {
$this->timestamp = new DateTimeImmutable('2024-01-15 10:30:45', new DateTimeZone('Europe/Berlin'));
$this->basicContext = LogContext::withData(['user_id' => 123, 'action' => 'login']);
});
describe('LineFormatter', function () {
it('formats basic log record', function () {
$formatter = new LineFormatter();
$record = new LogRecord('Test message', $this->basicContext, LogLevel::INFO, $this->timestamp);
$output = $formatter($record);
expect($output)->toContain('2024-01-15 10:30:45');
expect($output)->toContain('INFO');
expect($output)->toContain('Test message');
expect($output)->toContain('user_id');
});
it('supports custom format template', function () {
$formatter = new LineFormatter('{level}: {message}');
$record = new LogRecord('Custom format', $this->basicContext, LogLevel::WARNING, $this->timestamp);
$output = $formatter($record);
expect($output)->toBe('WARNING: Custom format');
});
it('includes channel in output', function () {
$formatter = new LineFormatter();
$record = new LogRecord('Channel test', $this->basicContext, LogLevel::ERROR, $this->timestamp, 'security');
$output = $formatter($record);
expect($output)->toContain('security.ERROR');
});
});
describe('JsonFormatter', function () {
it('formats record as valid JSON', function () {
$formatter = new JsonFormatter();
$record = new LogRecord('JSON test', $this->basicContext, LogLevel::DEBUG, $this->timestamp);
$output = $formatter($record);
$decoded = json_decode($output, true);
expect($decoded)->toBeArray();
expect($decoded['message'])->toBe('JSON test');
expect($decoded['level'])->toBe('DEBUG');
expect($decoded['context']['user_id'])->toBe(123);
});
it('formats with pretty print when enabled', function () {
$formatter = new JsonFormatter(prettyPrint: true);
$record = new LogRecord('Pretty JSON', $this->basicContext, LogLevel::INFO, $this->timestamp);
$output = $formatter($record);
expect($output)->toContain("\n");
expect($output)->toContain(" ");
});
it('excludes extras when disabled', function () {
$formatter = new JsonFormatter(includeExtras: false);
$record = new LogRecord('No extras', $this->basicContext, LogLevel::INFO, $this->timestamp);
$record->addExtra('test_extra', 'value');
$output = $formatter($record);
$decoded = json_decode($output, true);
expect($decoded)->not->toHaveKey('extra');
});
});
describe('DevelopmentFormatter', function () {
it('formats with human-readable output', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$record = new LogRecord('Dev message', $this->basicContext, LogLevel::ERROR, $this->timestamp);
$output = $formatter($record);
expect($output)->toContain('10:30:45');
expect($output)->toContain('ERROR');
expect($output)->toContain('Context:');
expect($output)->toContain('user_id: 123');
});
it('formats exception details', function () {
$formatter = new DevelopmentFormatter(colorOutput: false);
$exception = new Exception('Test exception');
$context = LogContext::withData(['exception' => $exception]);
$record = new LogRecord('Exception occurred', $context, LogLevel::ERROR, $this->timestamp);
// Simulate exception processor enrichment
$record->addExtras([
'exception_class' => Exception::class,
'exception_file' => __FILE__,
'exception_line' => __LINE__,
'exception_hash' => 'abc12345',
'exception_severity' => 'medium',
'stack_trace_short' => 'File.php:123 → Class::method()',
]);
$output = $formatter($record);
expect($output)->toContain('Exception:');
expect($output)->toContain('Class: Exception');
expect($output)->toContain('Hash: abc12345');
expect($output)->toContain('Trace: File.php:123');
});
});
describe('StructuredFormatter', function () {
it('formats as logfmt by default', function () {
$formatter = new StructuredFormatter();
$record = new LogRecord('Structured test', $this->basicContext, LogLevel::INFO, $this->timestamp);
$output = $formatter($record);
expect($output)->toBeString();
expect($output)->toContain('level=INFO');
expect($output)->toContain('msg="Structured test"');
expect($output)->toContain('user_id=123');
});
it('formats as key-value when specified', function () {
$formatter = new StructuredFormatter(format: 'kv');
$record = new LogRecord('KV test', $this->basicContext, LogLevel::WARNING, $this->timestamp);
$output = $formatter($record);
expect($output)->toContain('level: WARNING');
expect($output)->toContain('msg: KV test');
expect($output)->toContain(', ');
});
it('returns array when format is array', function () {
$formatter = new StructuredFormatter(format: 'array');
$record = new LogRecord('Array test', $this->basicContext, LogLevel::INFO, $this->timestamp);
$output = $formatter($record);
expect($output)->toBeArray();
expect($output['level'])->toBe('INFO');
expect($output['msg'])->toBe('Array test');
});
it('sanitizes keys properly', function () {
$context = LogContext::withData(['_internal_key' => 'value', 'key-with-dash' => 'value2']);
$formatter = new StructuredFormatter(format: 'array');
$record = new LogRecord('Sanitize test', $context, LogLevel::INFO, $this->timestamp);
$output = $formatter($record);
expect($output)->toHaveKey('internal_key'); // _ prefix removed
expect($output)->toHaveKey('key_with_dash'); // dash converted to underscore
});
});
describe('Formatter Integration', function () {
it('all formatters handle same record consistently', function () {
$context = LogContext::withData(['test' => 'value'])->addTags('integration');
$record = new LogRecord('Multi-formatter test', $context, LogLevel::INFO, $this->timestamp);
$lineFormatter = new LineFormatter();
$jsonFormatter = new JsonFormatter();
$devFormatter = new DevelopmentFormatter(colorOutput: false);
$structuredFormatter = new StructuredFormatter();
$lineOutput = $lineFormatter($record);
$jsonOutput = $jsonFormatter($record);
$devOutput = $devFormatter($record);
$structuredOutput = $structuredFormatter($record);
// All should contain the message
expect($lineOutput)->toContain('Multi-formatter test');
expect($jsonOutput)->toContain('Multi-formatter test');
expect($devOutput)->toContain('Multi-formatter test');
expect($structuredOutput)->toContain('Multi-formatter test');
// JSON should be valid
expect(json_decode($jsonOutput))->not->toBeNull();
});
});

View File

@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging\Handlers;
use App\Framework\Logging\Handlers\ConsoleHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
use DateTimeImmutable;
use DateTimeZone;
beforeEach(function () {
$this->timestamp = new DateTimeImmutable('2024-01-15 10:30:45', new DateTimeZone('Europe/Berlin'));
$this->context = LogContext::withData(['test' => 'data']);
});
it('only handles records in CLI mode', function () {
// ConsoleHandler should only work in CLI mode
expect(PHP_SAPI)->toBe('cli');
$handler = new ConsoleHandler();
$record = new LogRecord(
message: 'Test message',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
// In CLI mode, should handle the record
expect($handler->isHandling($record))->toBeTrue();
});
it('respects minimum level configuration', function () {
$handler = new ConsoleHandler(minLevel: LogLevel::WARNING);
$debugRecord = new LogRecord(
message: 'Debug',
context: $this->context,
level: LogLevel::DEBUG,
timestamp: $this->timestamp
);
$infoRecord = new LogRecord(
message: 'Info',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$warningRecord = new LogRecord(
message: 'Warning',
context: $this->context,
level: LogLevel::WARNING,
timestamp: $this->timestamp
);
$errorRecord = new LogRecord(
message: 'Error',
context: $this->context,
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
expect($handler->isHandling($debugRecord))->toBeFalse();
expect($handler->isHandling($infoRecord))->toBeFalse();
expect($handler->isHandling($warningRecord))->toBeTrue();
expect($handler->isHandling($errorRecord))->toBeTrue();
});
it('respects debug only mode when APP_DEBUG is not set', function () {
// Save original value
$originalDebug = getenv('APP_DEBUG');
// Test with APP_DEBUG = false
putenv('APP_DEBUG=false');
$handler = new ConsoleHandler(debugOnly: true);
$record = new LogRecord(
message: 'Test',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
expect($handler->isHandling($record))->toBeFalse();
// Test with APP_DEBUG = true
putenv('APP_DEBUG=true');
expect($handler->isHandling($record))->toBeTrue();
// Test with debugOnly = false (should always handle)
putenv('APP_DEBUG=false');
$handler = new ConsoleHandler(debugOnly: false);
expect($handler->isHandling($record))->toBeTrue();
// Restore original value
if ($originalDebug !== false) {
putenv("APP_DEBUG={$originalDebug}");
} else {
putenv('APP_DEBUG');
}
});
it('can change minimum level after creation', function () {
$handler = new ConsoleHandler(minLevel: LogLevel::DEBUG);
$infoRecord = new LogRecord(
message: 'Info',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
expect($handler->isHandling($infoRecord))->toBeTrue();
$handler->setMinLevel(LogLevel::WARNING);
expect($handler->isHandling($infoRecord))->toBeFalse();
});
it('can change output format', function () {
$handler = new ConsoleHandler();
$originalFormat = '{color}[{level_name}]{reset} {timestamp} {request_id}{message}{structured}';
$newFormat = '{level_name}: {message}';
$handler->setOutputFormat($newFormat);
// Note: We can't easily test the actual output without mocking file_put_contents or echo,
// but we can verify the method returns the handler for fluent interface
expect($handler->setOutputFormat($newFormat))->toBe($handler);
});
it('handles output correctly using stdout and stderr', function () {
// Save original APP_DEBUG value
$originalDebug = getenv('APP_DEBUG');
putenv('APP_DEBUG=true');
$handler = new ConsoleHandler(stderrLevel: LogLevel::WARNING);
// Test that lower levels would go to stdout (DEBUG, INFO, NOTICE)
$infoRecord = new LogRecord(
message: 'Info message',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
// Test that higher levels would go to stderr (WARNING and above)
$errorRecord = new LogRecord(
message: 'Error message',
context: $this->context,
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
// We can verify the handler processes these records
expect($handler->isHandling($infoRecord))->toBeTrue();
expect($handler->isHandling($errorRecord))->toBeTrue();
// Capture output
ob_start();
$handler->handle($infoRecord);
$stdoutOutput = ob_get_clean();
// For stderr, we would need to redirect stderr to test it properly
// This is complex in PHPUnit/Pest, so we just verify it handles the record
ob_start();
$stderrBefore = ob_get_clean();
$handler->handle($errorRecord);
// The handler should have processed both records
expect($handler->isHandling($infoRecord))->toBeTrue();
expect($handler->isHandling($errorRecord))->toBeTrue();
// Restore original value
if ($originalDebug !== false) {
putenv("APP_DEBUG={$originalDebug}");
} else {
putenv('APP_DEBUG');
}
});
it('formats records with extra data correctly', function () {
// Save original APP_DEBUG value
$originalDebug = getenv('APP_DEBUG');
putenv('APP_DEBUG=true');
$handler = new ConsoleHandler();
$record = new LogRecord(
message: 'Test with extras',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
// Add various types of extra data
$record->addExtra('request_id', 'req-123');
$record->addExtra('structured_tags', ['important', 'audit']);
$record->addExtra('trace_context', [
'trace_id' => 'trace-abc-def-123',
'active_span' => ['spanId' => 'span-456-789'],
]);
$record->addExtra('user_context', [
'user_id' => 'user-999',
'is_authenticated' => true,
]);
$record->addExtra('request_context', [
'request_method' => 'POST',
'request_uri' => '/api/users/create',
]);
// The handler should process this record
expect($handler->isHandling($record))->toBeTrue();
// Capture the output
ob_start();
$handler->handle($record);
$output = ob_get_clean();
// The output should contain the message
expect($output)->toContain('Test with extras');
// It should contain the request_id
expect($output)->toContain('req-123');
// Restore original value
if ($originalDebug !== false) {
putenv("APP_DEBUG={$originalDebug}");
} else {
putenv('APP_DEBUG');
}
});
it('handles records with channel information', function () {
// Save original APP_DEBUG value
$originalDebug = getenv('APP_DEBUG');
putenv('APP_DEBUG=true');
$handler = new ConsoleHandler(
outputFormat: '{channel}{level_name}: {message}'
);
$record = new LogRecord(
message: 'Database connection established',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp,
channel: 'database'
);
expect($handler->isHandling($record))->toBeTrue();
// Capture output
ob_start();
$handler->handle($record);
$output = ob_get_clean();
// The output should contain the channel
expect($output)->toContain('[database]');
expect($output)->toContain('Database connection established');
// Restore original value
if ($originalDebug !== false) {
putenv("APP_DEBUG={$originalDebug}");
} else {
putenv('APP_DEBUG');
}
});
it('applies correct colors for different log levels', function () {
// Save original APP_DEBUG value
$originalDebug = getenv('APP_DEBUG');
putenv('APP_DEBUG=true');
$handler = new ConsoleHandler();
$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: "{$level->getName()} message",
context: $this->context,
level: $level,
timestamp: $this->timestamp
);
ob_start();
$handler->handle($record);
$output = ob_get_clean();
// Each level should have its color code in the output
$expectedColor = $level->getConsoleColor()->value;
expect($output)->toContain($expectedColor);
expect($output)->toContain("{$level->getName()} message");
}
// Restore original value
if ($originalDebug !== false) {
putenv("APP_DEBUG={$originalDebug}");
} else {
putenv('APP_DEBUG');
}
});

View File

@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging\Handlers;
use App\Framework\Core\PathProvider;
use App\Framework\Logging\Handlers\MultiFileHandler;
use App\Framework\Logging\LogConfig;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
// Test-spezifische LogConfig Implementation
final class TestLogConfig
{
public function __construct(private string $testDir)
{
}
public function getLogPath(string $type): string
{
return match ($type) {
'security' => $this->testDir . '/security/security.log',
'cache' => $this->testDir . '/debug/cache.log',
'database' => $this->testDir . '/debug/database.log',
'framework' => $this->testDir . '/debug/framework.log',
'error' => $this->testDir . '/app/error.log',
default => $this->testDir . '/app/app.log'
};
}
public function getAllLogPaths(): array
{
return [
'security' => $this->getLogPath('security'),
'cache' => $this->getLogPath('cache'),
'database' => $this->getLogPath('database'),
'framework' => $this->getLogPath('framework'),
'error' => $this->getLogPath('error'),
'app' => $this->getLogPath('app'),
];
}
public function getBaseLogPath(): string
{
return $this->testDir;
}
public function ensureLogDirectoriesExist(): void
{
// No-op for testing
}
}
// Test-spezifische PathProvider Implementation
final class TestPathProvider
{
public function resolvePath(string $path): string
{
return $path; // Return path as-is for testing
}
}
final class MultiFileHandlerTest extends TestCase
{
private string $testLogDir;
private TestLogConfig $mockLogConfig;
private TestPathProvider $mockPathProvider;
private MultiFileHandler $handler;
protected function setUp(): void
{
// Temporäres Test-Verzeichnis erstellen
$this->testLogDir = sys_get_temp_dir() . '/test_logs_' . uniqid();
mkdir($this->testLogDir, 0755, true);
// Test-spezifische LogConfig mit direkten Pfaden
$this->mockLogConfig = $this->createLogConfigWithTestPaths();
$this->mockPathProvider = new TestPathProvider();
$this->handler = new MultiFileHandler(
$this->mockLogConfig,
$this->mockPathProvider,
LogLevel::DEBUG,
'[{timestamp}] [{level_name}] [{channel}] {message}'
);
}
protected function tearDown(): void
{
// Cleanup: Test-Verzeichnis löschen
if (is_dir($this->testLogDir)) {
$this->deleteDirectory($this->testLogDir);
}
}
private function deleteDirectory(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
}
rmdir($dir);
}
public function test_is_handling_respects_min_level(): void
{
$debugRecord = new LogRecord('Debug message', [], LogLevel::DEBUG, new DateTimeImmutable());
$errorRecord = new LogRecord('Error message', [], LogLevel::ERROR, new DateTimeImmutable());
expect($this->handler->isHandling($debugRecord))->toBeTrue();
expect($this->handler->isHandling($errorRecord))->toBeTrue();
// Handler mit höherem Min-Level
$errorHandler = new MultiFileHandler(
$this->mockLogConfig,
$this->mockPathProvider,
LogLevel::ERROR
);
expect($errorHandler->isHandling($debugRecord))->toBeFalse();
expect($errorHandler->isHandling($errorRecord))->toBeTrue();
}
public function test_handles_security_channel_logs(): void
{
$record = new LogRecord(
'Security alert',
['ip' => '192.168.1.1'],
LogLevel::WARNING,
new DateTimeImmutable('2023-12-25 10:30:45'),
'security'
);
$this->handler->handle($record);
$logFile = $this->testLogDir . '/security/security.log';
expect(file_exists($logFile))->toBeTrue();
$content = file_get_contents($logFile);
expect($content)->toContain('[2023-12-25 10:30:45] [WARNING] [security] Security alert');
expect($content)->toContain('{"ip":"192.168.1.1"}');
}
public function test_handles_cache_channel_logs(): void
{
$record = new LogRecord(
'Cache miss',
['key' => 'user_123'],
LogLevel::DEBUG,
new DateTimeImmutable('2023-12-25 10:30:45'),
'cache'
);
$this->handler->handle($record);
$logFile = $this->testLogDir . '/debug/cache.log';
expect(file_exists($logFile))->toBeTrue();
$content = file_get_contents($logFile);
expect($content)->toContain('[2023-12-25 10:30:45] [DEBUG] [cache] Cache miss');
expect($content)->toContain('{"key":"user_123"}');
}
public function test_handles_database_channel_logs(): void
{
$record = new LogRecord(
'Query failed',
['query' => 'SELECT * FROM users'],
LogLevel::ERROR,
new DateTimeImmutable('2023-12-25 10:30:45'),
'database'
);
$this->handler->handle($record);
$logFile = $this->testLogDir . '/debug/database.log';
expect(file_exists($logFile))->toBeTrue();
$content = file_get_contents($logFile);
expect($content)->toContain('[2023-12-25 10:30:45] [ERROR] [database] Query failed');
expect($content)->toContain('{"query":"SELECT * FROM users"}');
}
public function test_handles_framework_channel_logs(): void
{
$record = new LogRecord(
'Route registered',
['route' => '/api/users'],
LogLevel::INFO,
new DateTimeImmutable('2023-12-25 10:30:45'),
'framework'
);
$this->handler->handle($record);
$logFile = $this->testLogDir . '/debug/framework.log';
expect(file_exists($logFile))->toBeTrue();
$content = file_get_contents($logFile);
expect($content)->toContain('[2023-12-25 10:30:45] [INFO] [framework] Route registered');
expect($content)->toContain('{"route":"/api/users"}');
}
public function test_handles_error_channel_logs(): void
{
$record = new LogRecord(
'System failure',
['component' => 'payment'],
LogLevel::CRITICAL,
new DateTimeImmutable('2023-12-25 10:30:45'),
'error'
);
$this->handler->handle($record);
$logFile = $this->testLogDir . '/app/error.log';
expect(file_exists($logFile))->toBeTrue();
$content = file_get_contents($logFile);
expect($content)->toContain('[2023-12-25 10:30:45] [CRITICAL] [error] System failure');
expect($content)->toContain('{"component":"payment"}');
}
public function test_handles_app_channel_fallback(): void
{
$record = new LogRecord(
'Default message',
[],
LogLevel::INFO,
new DateTimeImmutable('2023-12-25 10:30:45'),
'unknown_channel'
);
$this->handler->handle($record);
$logFile = $this->testLogDir . '/app/app.log';
expect(file_exists($logFile))->toBeTrue();
$content = file_get_contents($logFile);
expect($content)->toContain('[2023-12-25 10:30:45] [INFO] [unknown_channel] Default message');
}
public function test_handles_null_channel(): void
{
$record = new LogRecord(
'No channel message',
[],
LogLevel::INFO,
new DateTimeImmutable('2023-12-25 10:30:45'),
null
);
$this->handler->handle($record);
$logFile = $this->testLogDir . '/app/app.log';
expect(file_exists($logFile))->toBeTrue();
$content = file_get_contents($logFile);
expect($content)->toContain('[2023-12-25 10:30:45] [INFO] [app] No channel message');
}
public function test_creates_directories_automatically(): void
{
$record = new LogRecord(
'Test message',
[],
LogLevel::INFO,
new DateTimeImmutable(),
'security'
);
// Verzeichnis sollte nicht existieren
expect(is_dir($this->testLogDir . '/security'))->toBeFalse();
$this->handler->handle($record);
// Verzeichnis sollte automatisch erstellt worden sein
expect(is_dir($this->testLogDir . '/security'))->toBeTrue();
expect(file_exists($this->testLogDir . '/security/security.log'))->toBeTrue();
}
public function test_appends_to_existing_files(): void
{
$record1 = new LogRecord(
'First message',
[],
LogLevel::INFO,
new DateTimeImmutable('2023-12-25 10:30:45'),
'security'
);
$record2 = new LogRecord(
'Second message',
[],
LogLevel::WARNING,
new DateTimeImmutable('2023-12-25 10:31:45'),
'security'
);
$this->handler->handle($record1);
$this->handler->handle($record2);
$logFile = $this->testLogDir . '/security/security.log';
$content = file_get_contents($logFile);
expect($content)->toContain('First message');
expect($content)->toContain('Second message');
// Zwei Zeilen sollten vorhanden sein
$lines = explode("\n", trim($content));
expect($lines)->toHaveCount(2);
}
public function test_handles_empty_context(): void
{
$record = new LogRecord(
'Message without context',
[],
LogLevel::INFO,
new DateTimeImmutable('2023-12-25 10:30:45'),
'security'
);
$this->handler->handle($record);
$logFile = $this->testLogDir . '/security/security.log';
$content = file_get_contents($logFile);
// Sollte keine JSON-Context-Info enthalten
expect($content)->toContain('Message without context');
expect($content)->not->toContain('{}');
expect($content)->not->toContain('[]');
}
private function createLogConfigWithTestPaths(): TestLogConfig
{
return new TestLogConfig($this->testLogDir);
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging;
use App\Framework\Logging\LogChannel;
use PHPUnit\Framework\TestCase;
final class LogChannelTest extends TestCase
{
public function test_can_get_log_path_key(): void
{
expect(LogChannel::APP->getLogPathKey())->toBe('app');
expect(LogChannel::SECURITY->getLogPathKey())->toBe('security');
expect(LogChannel::CACHE->getLogPathKey())->toBe('cache');
expect(LogChannel::DATABASE->getLogPathKey())->toBe('database');
expect(LogChannel::FRAMEWORK->getLogPathKey())->toBe('framework');
expect(LogChannel::ERROR->getLogPathKey())->toBe('error');
}
public function test_can_get_description(): void
{
expect(LogChannel::APP->getDescription())->toBe('Application logs');
expect(LogChannel::SECURITY->getDescription())->toBe('Security events and authentication logs');
expect(LogChannel::CACHE->getDescription())->toBe('Cache operations and debugging');
expect(LogChannel::DATABASE->getDescription())->toBe('Database queries and operations');
expect(LogChannel::FRAMEWORK->getDescription())->toBe('Framework internals and debugging');
expect(LogChannel::ERROR->getDescription())->toBe('Error-specific logs');
}
public function test_can_get_all_channels(): void
{
$channels = LogChannel::getAllChannels();
expect($channels)->toBeArray();
expect($channels)->toHaveCount(6);
expect($channels['app'])->toBe('Application logs');
expect($channels['security'])->toBe('Security events and authentication logs');
expect($channels['cache'])->toBe('Cache operations and debugging');
expect($channels['database'])->toBe('Database queries and operations');
expect($channels['framework'])->toBe('Framework internals and debugging');
expect($channels['error'])->toBe('Error-specific logs');
}
public function test_enum_values_are_correct(): void
{
expect(LogChannel::APP->value)->toBe('app');
expect(LogChannel::SECURITY->value)->toBe('security');
expect(LogChannel::CACHE->value)->toBe('cache');
expect(LogChannel::DATABASE->value)->toBe('database');
expect(LogChannel::FRAMEWORK->value)->toBe('framework');
expect(LogChannel::ERROR->value)->toBe('error');
}
public function test_all_enum_cases_are_covered(): void
{
$expectedCases = ['app', 'security', 'cache', 'database', 'framework', 'error'];
$actualCases = array_map(fn ($case) => $case->value, LogChannel::cases());
expect($actualCases)->toBe($expectedCases);
}
}

View File

@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ValueObjects\LogContext;
use DateTimeImmutable;
use DateTimeZone;
beforeEach(function () {
$this->timestamp = new DateTimeImmutable('2024-01-15 10:30:45', new DateTimeZone('Europe/Berlin'));
$this->context = LogContext::withData(['user' => 'test', 'action' => 'login']);
});
it('can create a log record with required fields', function () {
$record = new LogRecord(
message: 'Test message',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
expect($record->getMessage())->toBe('Test message');
expect($record->getLevel())->toBe(LogLevel::INFO);
expect($record->getTimestamp())->toBe($this->timestamp);
expect($record->getChannel())->toBeNull();
});
it('can create a log record with channel', function () {
$record = new LogRecord(
message: 'Database query executed',
context: $this->context,
level: LogLevel::DEBUG,
timestamp: $this->timestamp,
channel: 'database'
);
expect($record->getChannel())->toBe('database');
});
it('can modify message', function () {
$record = new LogRecord(
message: 'Original message',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$record->setMessage('Modified message');
expect($record->getMessage())->toBe('Modified message');
});
it('can set channel after creation', function () {
$record = new LogRecord(
message: 'Test',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$record->setChannel('security');
expect($record->getChannel())->toBe('security');
});
it('can format timestamp', function () {
$record = new LogRecord(
message: 'Test',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
expect($record->getFormattedTimestamp())->toBe('2024-01-15 10:30:45');
expect($record->getFormattedTimestamp('Y-m-d'))->toBe('2024-01-15');
expect($record->getFormattedTimestamp('H:i:s'))->toBe('10:30:45');
});
it('can add extra data', function () {
$record = new LogRecord(
message: 'Test',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$record->addExtra('request_id', 'req-123');
$record->addExtra('process_id', 5678);
expect($record->hasExtra('request_id'))->toBeTrue();
expect($record->getExtra('request_id'))->toBe('req-123');
expect($record->getExtra('process_id'))->toBe(5678);
expect($record->hasExtra('non_existent'))->toBeFalse();
});
it('can add multiple extras at once', function () {
$record = new LogRecord(
message: 'Test',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$extras = [
'server' => 'web-01',
'memory_usage' => 1024000,
'execution_time' => 0.123,
];
$record->addExtras($extras);
expect($record->getExtras())->toBe($extras);
expect($record->getExtra('server'))->toBe('web-01');
expect($record->getExtra('memory_usage'))->toBe(1024000);
expect($record->getExtra('execution_time'))->toBe(0.123);
});
it('returns default value for non-existent extra', function () {
$record = new LogRecord(
message: 'Test',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
expect($record->getExtra('missing'))->toBeNull();
expect($record->getExtra('missing', 'default'))->toBe('default');
});
it('maintains fluent interface for setters', function () {
$record = new LogRecord(
message: 'Test',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$result = $record
->setMessage('New message')
->setChannel('custom')
->addExtra('key1', 'value1')
->addExtras(['key2' => 'value2']);
expect($result)->toBe($record);
expect($record->getMessage())->toBe('New message');
expect($record->getChannel())->toBe('custom');
expect($record->getExtra('key1'))->toBe('value1');
expect($record->getExtra('key2'))->toBe('value2');
});
it('converts to array correctly', function () {
$record = new LogRecord(
message: 'Test message',
context: $this->context,
level: LogLevel::WARNING,
timestamp: $this->timestamp,
channel: 'test-channel'
);
$record->addExtra('request_id', 'req-456');
$array = $record->toArray();
expect($array)->toHaveKeys([
'message',
'context',
'level',
'level_name',
'timestamp',
'datetime',
'channel',
'extra',
]);
expect($array['message'])->toBe('Test message');
expect($array['context'])->toMatchArray(['user' => 'test', 'action' => 'login']);
expect($array['level'])->toBe(LogLevel::WARNING->value);
expect($array['level_name'])->toBe('WARNING');
expect($array['timestamp'])->toBe('2024-01-15 10:30:45');
expect($array['datetime'])->toBe($this->timestamp);
expect($array['channel'])->toBe('test-channel');
expect($array['extra'])->toBe(['request_id' => 'req-456']);
});
it('handles different log levels correctly', function () {
$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 {$level->getName()}",
context: $this->context,
level: $level,
timestamp: $this->timestamp
);
expect($record->getLevel())->toBe($level);
expect($record->toArray()['level'])->toBe($level->value);
expect($record->toArray()['level_name'])->toBe($level->getName());
}
});
it('preserves context as LogContext object', function () {
$context = LogContext::withData(['test' => 'data'])
->addTags('important', 'audit');
$record = new LogRecord(
message: 'Test',
context: $context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$contextArray = $record->getContext();
expect($contextArray)->toMatchArray([
'test' => 'data',
'_tags' => ['important', 'audit'],
]);
});
it('can handle empty extras', function () {
$record = new LogRecord(
message: 'Test',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
expect($record->getExtras())->toBe([]);
expect($record->hasExtra('any'))->toBeFalse();
});
it('overrides existing extras when adding with same key', function () {
$record = new LogRecord(
message: 'Test',
context: $this->context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$record->addExtra('key', 'original');
expect($record->getExtra('key'))->toBe('original');
$record->addExtra('key', 'modified');
expect($record->getExtra('key'))->toBe('modified');
$record->addExtras(['key' => 'final']);
expect($record->getExtra('key'))->toBe('final');
});

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Logging\Processors;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\Processors\ExceptionEnrichmentProcessor;
use App\Framework\Logging\ValueObjects\LogContext;
use DateTimeImmutable;
use Exception;
use InvalidArgumentException;
use RuntimeException;
beforeEach(function () {
$this->processor = new ExceptionEnrichmentProcessor();
$this->timestamp = new DateTimeImmutable();
});
it('enriches log record with basic exception information', function () {
$exception = new RuntimeException('Test error message', 123);
$context = LogContext::withData(['exception' => $exception]);
$record = new LogRecord(
message: 'An error occurred',
context: $context,
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$enrichedRecord = $this->processor->processRecord($record);
$extras = $enrichedRecord->getExtras();
expect($extras['exception_class'])->toBe(RuntimeException::class);
expect($extras['exception_message'])->toBe('Test error message');
expect($extras['exception_code'])->toBe(123);
expect($extras['exception_file'])->toBeString();
expect($extras['exception_line'])->toBeInt();
});
it('includes formatted stack trace', function () {
$exception = new Exception('Test exception');
$context = LogContext::withData(['exception' => $exception]);
$record = new LogRecord(
message: 'Error with stack trace',
context: $context,
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$enrichedRecord = $this->processor->processRecord($record);
$extras = $enrichedRecord->getExtras();
expect($extras['stack_trace'])->toBeArray();
expect($extras['stack_trace_short'])->toBeString();
expect($extras['stack_trace'])->not->toBeEmpty();
});
it('handles previous exceptions in chain', function () {
$innerException = new InvalidArgumentException('Inner error');
$outerException = new RuntimeException('Outer error', 0, $innerException);
$context = LogContext::withData(['exception' => $outerException]);
$record = new LogRecord(
message: 'Chained exception',
context: $context,
level: LogLevel::ERROR,
timestamp: $this->timestamp
);
$enrichedRecord = $this->processor->processRecord($record);
$extras = $enrichedRecord->getExtras();
expect($extras['previous_exceptions'])->toBeArray();
expect($extras['previous_exceptions'])->toHaveCount(1);
expect($extras['previous_exceptions'][0]['class'])->toBe(InvalidArgumentException::class);
expect($extras['previous_exceptions'][0]['message'])->toBe('Inner error');
});
it('generates exception hash', function () {
$exception = new Exception('Test message');
$context = LogContext::withData(['exception' => $exception]);
$record = new LogRecord('Error', $context, LogLevel::ERROR, $this->timestamp);
$enriched = $this->processor->processRecord($record);
$hash = $enriched->getExtra('exception_hash');
expect($hash)->toBeString();
expect($hash)->toHaveLength(8); // MD5 first 8 chars
});
it('categorizes exception severity correctly', function () {
$cases = [
[new InvalidArgumentException('Invalid arg'), 'medium'],
[new RuntimeException('Runtime error'), 'medium'],
[new Exception('Generic exception'), 'unknown'],
];
foreach ($cases as [$exception, $expectedSeverity]) {
$context = LogContext::withData(['exception' => $exception]);
$record = new LogRecord('Test', $context, LogLevel::ERROR, $this->timestamp);
$enriched = $this->processor->processRecord($record);
$severity = $enriched->getExtra('exception_severity');
expect($severity)->toBe($expectedSeverity);
}
});
it('returns record unchanged when no exception present', function () {
$context = LogContext::withData(['some_data' => 'value']);
$record = new LogRecord(
message: 'No exception here',
context: $context,
level: LogLevel::INFO,
timestamp: $this->timestamp
);
$processedRecord = $this->processor->processRecord($record);
expect($processedRecord->getExtras())->toBeEmpty();
expect($processedRecord->getMessage())->toBe('No exception here');
});
it('finds exception nested in context', function () {
$exception = new Exception('Nested exception');
$context = LogContext::withData([
'user_data' => ['id' => 123],
'error_info' => $exception, // Exception nested in context
]);
$record = new LogRecord('Nested error', $context, LogLevel::ERROR, $this->timestamp);
$enriched = $this->processor->processRecord($record);
expect($enriched->getExtra('exception_class'))->toBe(Exception::class);
expect($enriched->getExtra('exception_message'))->toBe('Nested exception');
});
it('respects max stack trace depth', function () {
$processor = new ExceptionEnrichmentProcessor(maxStackTraceDepth: 3);
$exception = new Exception('Deep stack trace');
$context = LogContext::withData(['exception' => $exception]);
$record = new LogRecord('Deep trace', $context, LogLevel::ERROR, $this->timestamp);
$enriched = $processor->processRecord($record);
$stackTrace = $enriched->getExtra('stack_trace');
expect(count($stackTrace))->toBeLessThanOrEqual(3);
});
it('has correct processor metadata', function () {
expect($this->processor->getName())->toBe('exception_enrichment');
expect($this->processor->getPriority())->toBe(100);
});
it('formats short stack trace readably', function () {
$exception = new Exception('Test exception');
$context = LogContext::withData(['exception' => $exception]);
$record = new LogRecord('Test', $context, LogLevel::ERROR, $this->timestamp);
$enriched = $this->processor->processRecord($record);
$shortTrace = $enriched->getExtra('stack_trace_short');
expect($shortTrace)->toBeString();
expect($shortTrace)->toContain('→'); // Contains arrow separator
expect($shortTrace)->toContain('.php:'); // Contains file and line
});