Add log viewer and tests for the logs and the viewer (#41)
Now that I'm adding more logging, I wanted to add a log viewer so people don't have to ssh to their servers to inspect logs. Also added tests around logging and the viewer. Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/41 Co-authored-by: Greg Sarjeant <greg@subcultureofone.org> Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
		
							parent
							
								
									dc0abf8c7c
								
							
						
					
					
						commit
						a7e79796fa
					
				| @ -15,7 +15,7 @@ jobs: | ||||
|           tar \ | ||||
|             --transform 's,^,tkr/,' \ | ||||
|             --exclude='storage/db' \ | ||||
|             --exclude='storage/ticks' \ | ||||
|             --exclude='storage/logs' \ | ||||
|             --exclude='storage/upload' \ | ||||
|             -czvf tkr.${{ gitea.ref_name }}.tgz \ | ||||
|             check-prerequisites.php config public src storage templates | ||||
|  | ||||
| @ -18,6 +18,10 @@ | ||||
|     --color-primary: gainsboro; | ||||
|     --color-required: crimson; | ||||
|     --color-text: black; | ||||
|     --color-log-info-bg: #f8f9fa; | ||||
|     --color-log-warning-bg: #fff8e1; | ||||
|     --color-log-error-bg: #ffebee; | ||||
|     --color-log-muted: #666; | ||||
| 
 | ||||
|     --border-width: 2px; | ||||
|     --border-width-thin: 1px; | ||||
| @ -497,3 +501,53 @@ time { | ||||
|         min-width: auto; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /* Log viewer styles */ | ||||
| .log-monospace { | ||||
|     font-family: 'Courier New', Consolas, Monaco, 'Lucida Console', monospace; | ||||
| } | ||||
| 
 | ||||
| .log-table { | ||||
|     width: 100%; | ||||
|     border-collapse: collapse; | ||||
|     margin-bottom: 1rem; | ||||
| } | ||||
| 
 | ||||
| .log-table th, | ||||
| .log-table td { | ||||
|     padding: 10px 12px; | ||||
|     text-align: left; | ||||
|     border-bottom: var(--border-width-thin) solid var(--color-border); | ||||
| } | ||||
| 
 | ||||
| .log-table th { | ||||
|     background-color: var(--color-primary); | ||||
|     font-weight: 600; | ||||
|     color: var(--color-text); | ||||
| } | ||||
| 
 | ||||
| .log-level-badge { | ||||
|     padding: 4px 8px; | ||||
|     border-radius: var(--border-radius); | ||||
|     font-size: 0.8em; | ||||
|     font-weight: 600; | ||||
| } | ||||
| 
 | ||||
| /* Log level row colors - subtle background tints */ | ||||
| .log-debug { background-color: var(--color-bg); } | ||||
| .log-info { background-color: var(--color-log-info-bg); } | ||||
| .log-warning { background-color: var(--color-log-warning-bg); } | ||||
| .log-error { background-color: var(--color-log-error-bg); } | ||||
| 
 | ||||
| .log-no-route { | ||||
|     color: var(--color-log-muted); | ||||
|     font-style: italic; | ||||
| } | ||||
| 
 | ||||
| .log-info { | ||||
|     margin-top: 1rem; | ||||
|     padding: 10px 12px; | ||||
|     background-color: var(--color-primary); | ||||
|     border-radius: var(--border-radius); | ||||
|     font-size: 0.9em; | ||||
| } | ||||
|  | ||||
							
								
								
									
										122
									
								
								src/Controller/LogController/LogController.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								src/Controller/LogController/LogController.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,122 @@ | ||||
| <?php | ||||
| class LogController extends Controller { | ||||
|     private string $storageDir; | ||||
| 
 | ||||
|     public function __construct(?string $storageDir = null) { | ||||
|         $this->storageDir = $storageDir ?? STORAGE_DIR; | ||||
|     } | ||||
| 
 | ||||
|     public function index() { | ||||
|         // Ensure user is logged in
 | ||||
|         if (!Session::isLoggedIn()) { | ||||
|             global $config; | ||||
|             header('Location: ' . Util::buildRelativeUrl($config->basePath, 'login')); | ||||
|             exit; | ||||
|         } | ||||
| 
 | ||||
|         // Get filter parameters
 | ||||
|         $levelFilter = $_GET['level'] ?? ''; | ||||
|         $routeFilter = $_GET['route'] ?? ''; | ||||
| 
 | ||||
|         // Get the data for the template
 | ||||
|         $data = $this->getLogData($levelFilter, $routeFilter); | ||||
| 
 | ||||
|         $this->render('logs.php', $data); | ||||
|     } | ||||
| 
 | ||||
|     public function getLogData(string $levelFilter = '', string $routeFilter = ''): array { | ||||
|         global $config; | ||||
| 
 | ||||
|         $limit = 300; // Show last 300 log entries
 | ||||
| 
 | ||||
|         // Read and parse log entries
 | ||||
|         $logEntries = $this->getLogEntries($limit, $levelFilter, $routeFilter); | ||||
| 
 | ||||
|         // Get available routes and levels for filter dropdowns
 | ||||
|         $availableRoutes = $this->getAvailableRoutes(); | ||||
|         $availableLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR']; | ||||
| 
 | ||||
|         return [ | ||||
|             'config' => $config, | ||||
|             'logEntries' => $logEntries, | ||||
|             'availableRoutes' => $availableRoutes, | ||||
|             'availableLevels' => $availableLevels, | ||||
|             'currentLevelFilter' => $levelFilter, | ||||
|             'currentRouteFilter' => $routeFilter, | ||||
|         ]; | ||||
|     } | ||||
| 
 | ||||
|     private function getLogEntries(int $limit, string $levelFilter = '', string $routeFilter = ''): array { | ||||
|         $logFile = $this->storageDir . '/logs/tkr.log'; | ||||
|         $entries = []; | ||||
| 
 | ||||
|         // Read from current log file and rotated files
 | ||||
|         $logFiles = [$logFile]; | ||||
|         for ($i = 1; $i <= 5; $i++) { | ||||
|             $rotatedFile = $logFile . '.' . $i; | ||||
|             if (file_exists($rotatedFile)) { | ||||
|                 $logFiles[] = $rotatedFile; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         foreach ($logFiles as $file) { | ||||
|             if (file_exists($file)) { | ||||
|                 $lines = file($file, FILE_IGNORE_NEW_LINES); | ||||
|                 foreach (array_reverse($lines) as $line) { | ||||
|                     if (count($entries) >= $limit) break 2; | ||||
| 
 | ||||
|                     $entry = $this->parseLogLine($line); | ||||
|                     if ($entry && $this->matchesFilters($entry, $levelFilter, $routeFilter)) { | ||||
|                         $entries[] = $entry; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return $entries; | ||||
|     } | ||||
| 
 | ||||
|     private function parseLogLine(string $line): ?array { | ||||
|         // Parse format: [2025-01-31 08:30:15] DEBUG: 192.168.1.100 [GET feed/rss] - message
 | ||||
|         $pattern = '/^\[([^\]]+)\] (\w+): ([^\s]+)(?:\s+\[([^\]]+)\])? - (.+)$/'; | ||||
| 
 | ||||
|         if (preg_match($pattern, $line, $matches)) { | ||||
|             return [ | ||||
|                 'timestamp' => $matches[1], | ||||
|                 'level' => $matches[2], | ||||
|                 'ip' => $matches[3], | ||||
|                 'route' => $matches[4] ?? '', | ||||
|                 'message' => $matches[5], | ||||
|                 'raw' => $line | ||||
|             ]; | ||||
|         } | ||||
| 
 | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     private function matchesFilters(array $entry, string $levelFilter, string $routeFilter): bool { | ||||
|         if ($levelFilter && $entry['level'] !== $levelFilter) { | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|         if ($routeFilter && $entry['route'] !== $routeFilter) { | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     private function getAvailableRoutes(): array { | ||||
|         $routes = []; | ||||
|         $entries = $this->getLogEntries(1000); // Sample more entries to get route list
 | ||||
| 
 | ||||
|         foreach ($entries as $entry) { | ||||
|             if ($entry['route'] && !in_array($entry['route'], $routes)) { | ||||
|                 $routes[] = $entry['route']; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         sort($routes); | ||||
|         return $routes; | ||||
|     } | ||||
| } | ||||
| @ -76,7 +76,7 @@ class Database{ | ||||
|             Log::debug("No pending migrations"); | ||||
|             return; | ||||
|         } | ||||
|         Log::info("Found " . count($migrations) . "pending migrations."); | ||||
|         Log::info("Found " . count($migrations) . " pending migrations."); | ||||
|         Log::info("Updating database. Current Version: " . $this->getVersion()); | ||||
| 
 | ||||
|         $db = self::get(); | ||||
|  | ||||
| @ -12,8 +12,8 @@ class Log { | ||||
|     private static $maxFiles = 5; | ||||
|     private static $routeContext = ''; | ||||
| 
 | ||||
|     public static function init() { | ||||
|         self::$logFile = STORAGE_DIR . '/logs/tkr.log'; | ||||
|     public static function init(?string $logFile = null) { | ||||
|         self::$logFile = $logFile ?? STORAGE_DIR . '/logs/tkr.log'; | ||||
| 
 | ||||
|         // Ensure log directory exists
 | ||||
|         // (should be handled by Prerequisites, but doesn't hurt)
 | ||||
|  | ||||
| @ -12,6 +12,7 @@ class Router { | ||||
|         ['admin/css', 'CssController@handlePost', ['POST']], | ||||
|         ['admin/emoji', 'EmojiController'], | ||||
|         ['admin/emoji', 'EmojiController@handlePost', ['POST']], | ||||
|         ['admin/logs', 'LogController'], | ||||
|         ['feed/rss', 'FeedController@rss'], | ||||
|         ['feed/atom', 'FeedController@atom'], | ||||
|         ['login', 'AuthController@showLogin'], | ||||
|  | ||||
							
								
								
									
										90
									
								
								templates/partials/logs.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								templates/partials/logs.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,90 @@ | ||||
| <?php /** @var ConfigModel $config */ ?>
 | ||||
| <?php /** @var array $logEntries */ ?>
 | ||||
| <?php /** @var array $availableRoutes */ ?>
 | ||||
| <?php /** @var array $availableLevels */ ?>
 | ||||
| <?php /** @var string $currentLevelFilter */ ?>
 | ||||
| <?php /** @var string $currentRouteFilter */ ?>
 | ||||
|         <h1>System Logs</h1> | ||||
|         <main> | ||||
|             <!-- Filters --> | ||||
|             <div class="log-filters"> | ||||
|                 <form method="get" action="<?= Util::buildRelativeUrl($config->basePath, 'admin/logs') ?>"> | ||||
|                     <fieldset> | ||||
|                         <legend>Filter Logs</legend> | ||||
|                         <div class="fieldset-items"> | ||||
|                             <label for="level-filter">Level:</label> | ||||
|                             <select id="level-filter" name="level"> | ||||
|                                 <option value="">All Levels</option> | ||||
|                                 <?php foreach ($availableLevels as $level): ?>
 | ||||
|                                     <option value="<?= Util::escape_html($level) ?>" | ||||
|                                             <?= $currentLevelFilter === $level ? 'selected' : '' ?>>
 | ||||
|                                         <?= Util::escape_html($level) ?>
 | ||||
|                                     </option> | ||||
|                                 <?php endforeach; ?>
 | ||||
|                             </select> | ||||
| 
 | ||||
|                             <label for="route-filter">Route:</label> | ||||
|                             <select id="route-filter" name="route"> | ||||
|                                 <option value="">All Routes</option> | ||||
|                                 <?php foreach ($availableRoutes as $route): ?>
 | ||||
|                                     <option value="<?= Util::escape_html($route) ?>" | ||||
|                                             <?= $currentRouteFilter === $route ? 'selected' : '' ?>>
 | ||||
|                                         <?= Util::escape_html($route) ?>
 | ||||
|                                     </option> | ||||
|                                 <?php endforeach; ?>
 | ||||
|                             </select> | ||||
| 
 | ||||
|                             <div></div><button type="submit">Filter</button> | ||||
|                             <div></div><a href="<?= Util::buildRelativeUrl($config->basePath, 'admin/logs') ?>">Clear</a> | ||||
|                         </div> | ||||
|                     </fieldset> | ||||
|                 </form> | ||||
|             </div> | ||||
| 
 | ||||
|             <!-- Log entries table --> | ||||
|             <div class="log-entries"> | ||||
|                 <?php if (empty($logEntries)): ?>
 | ||||
|                     <p>No log entries found matching the current filters.</p> | ||||
|                 <?php else: ?>
 | ||||
|                     <table class="log-table"> | ||||
|                         <thead> | ||||
|                             <tr> | ||||
|                                 <th>Time</th> | ||||
|                                 <th>Level</th> | ||||
|                                 <th>IP</th> | ||||
|                                 <th>Route</th> | ||||
|                                 <th>Message</th> | ||||
|                             </tr> | ||||
|                         </thead> | ||||
|                         <tbody> | ||||
|                             <?php foreach ($logEntries as $entry): ?>
 | ||||
|                                 <tr class="log-entry log-<?= strtolower($entry['level']) ?>"> | ||||
|                                     <td class="log-timestamp log-monospace"> | ||||
|                                         <time datetime="<?= Util::escape_html($entry['timestamp']) ?>"> | ||||
|                                             <?= Util::escape_html($entry['timestamp']) ?>
 | ||||
|                                         </time> | ||||
|                                     </td> | ||||
|                                     <td class="log-level"> | ||||
|                                         <span class="log-level-badge"><?= Util::escape_html($entry['level']) ?></span>
 | ||||
|                                     </td> | ||||
|                                     <td class="log-ip log-monospace"><?= Util::escape_html($entry['ip']) ?></td>
 | ||||
|                                     <td class="log-route log-monospace"> | ||||
|                                         <?php if ($entry['route']): ?>
 | ||||
|                                             <?= Util::escape_html($entry['route']) ?>
 | ||||
|                                         <?php else: ?>
 | ||||
|                                             <span class="log-no-route">-</span> | ||||
|                                         <?php endif; ?>
 | ||||
|                                     </td> | ||||
|                                     <td class="log-message log-monospace"><?= Util::escape_html($entry['message']) ?></td>
 | ||||
|                                 </tr> | ||||
|                             <?php endforeach; ?>
 | ||||
|                         </tbody> | ||||
|                     </table> | ||||
|                 <?php endif; ?>
 | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="log-info"> | ||||
|                 <p>Showing <?= count($logEntries) ?> recent log entries.
 | ||||
|                    Log files are automatically rotated when they reach 1000 lines.</p> | ||||
|             </div> | ||||
|         </main> | ||||
| @ -25,6 +25,8 @@ | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/css')) ?>">css</a> | ||||
|                     <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/emoji')) ?>">emoji</a> | ||||
|                     <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|                        href="<?= Util::escape_html(Util::buildRelativeUrl($config->basePath, 'admin/logs')) ?>">logs</a> | ||||
|                 </div> | ||||
|             </details> | ||||
|             <a <?php if($config->strictAccessibility): ?>tabindex="0"<?php endif; ?>
 | ||||
|  | ||||
							
								
								
									
										237
									
								
								tests/Controller/LogController/LogControllerTest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										237
									
								
								tests/Controller/LogController/LogControllerTest.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,237 @@ | ||||
| <?php | ||||
| use PHPUnit\Framework\TestCase; | ||||
| 
 | ||||
| class LogControllerTest extends TestCase | ||||
| { | ||||
|     private string $tempLogDir; | ||||
|     private string $testLogFile; | ||||
|     private $originalGet; | ||||
| 
 | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         $this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid(); | ||||
|         mkdir($this->tempLogDir, 0777, true); | ||||
|          | ||||
|         $this->testLogFile = $this->tempLogDir . '/logs/tkr.log'; | ||||
|         mkdir(dirname($this->testLogFile), 0777, true); | ||||
| 
 | ||||
|         // Store original $_GET and clear it
 | ||||
|         $this->originalGet = $_GET; | ||||
|         $_GET = []; | ||||
| 
 | ||||
|         // Mock global config
 | ||||
|         global $config; | ||||
|         $config = new ConfigModel(); | ||||
|         $config->baseUrl = 'https://example.com'; | ||||
|         $config->basePath = '/tkr/'; | ||||
|     } | ||||
| 
 | ||||
|     protected function tearDown(): void | ||||
|     { | ||||
|         // Restore original $_GET
 | ||||
|         $_GET = $this->originalGet; | ||||
| 
 | ||||
|         if (is_dir($this->tempLogDir)) { | ||||
|             $this->deleteDirectory($this->tempLogDir); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     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 testGetLogDataWithNoLogFiles(): void | ||||
|     { | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
|         // Should return empty log entries but valid structure
 | ||||
|         $this->assertArrayHasKey('logEntries', $data); | ||||
|         $this->assertArrayHasKey('availableRoutes', $data); | ||||
|         $this->assertArrayHasKey('availableLevels', $data); | ||||
|         $this->assertArrayHasKey('currentLevelFilter', $data); | ||||
|         $this->assertArrayHasKey('currentRouteFilter', $data); | ||||
|          | ||||
|         $this->assertEmpty($data['logEntries']); | ||||
|         $this->assertEmpty($data['availableRoutes']); | ||||
|         $this->assertEquals(['DEBUG', 'INFO', 'WARNING', 'ERROR'], $data['availableLevels']); | ||||
|         $this->assertEquals('', $data['currentLevelFilter']); | ||||
|         $this->assertEquals('', $data['currentRouteFilter']); | ||||
|     } | ||||
| 
 | ||||
|     public function testGetLogDataWithValidEntries(): void | ||||
|     { | ||||
|         // Create test log content with various scenarios
 | ||||
|         $logContent = implode("\n", [ | ||||
|             '[2025-01-31 12:00:00] DEBUG: 127.0.0.1 [GET /] - Debug home page', | ||||
|             '[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Info admin page', | ||||
|             '[2025-01-31 12:02:00] WARNING: 127.0.0.1 [POST /admin] - Warning admin save', | ||||
|             '[2025-01-31 12:03:00] ERROR: 127.0.0.1 [GET /feed/rss] - Error feed generation', | ||||
|             '[2025-01-31 12:04:00] INFO: 127.0.0.1 - Info without route', | ||||
|             'Invalid log line that should be ignored' | ||||
|         ]); | ||||
| 
 | ||||
|         file_put_contents($this->testLogFile, $logContent); | ||||
| 
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
|         // Should parse all valid entries and ignore invalid ones
 | ||||
|         $this->assertCount(5, $data['logEntries']); | ||||
|          | ||||
|         // Verify entries are in reverse chronological order (newest first)
 | ||||
|         $entries = $data['logEntries']; | ||||
|         $this->assertEquals('Info without route', $entries[0]['message']); | ||||
|         $this->assertEquals('Debug home page', $entries[4]['message']); | ||||
|          | ||||
|         // Verify entry structure
 | ||||
|         $firstEntry = $entries[0]; | ||||
|         $this->assertArrayHasKey('timestamp', $firstEntry); | ||||
|         $this->assertArrayHasKey('level', $firstEntry); | ||||
|         $this->assertArrayHasKey('ip', $firstEntry); | ||||
|         $this->assertArrayHasKey('route', $firstEntry); | ||||
|         $this->assertArrayHasKey('message', $firstEntry); | ||||
|          | ||||
|         // Test route extraction
 | ||||
|         $adminEntry = array_filter($entries, fn($e) => $e['message'] === 'Info admin page'); | ||||
|         $adminEntry = array_values($adminEntry)[0]; | ||||
|         $this->assertEquals('GET /admin', $adminEntry['route']); | ||||
|         $this->assertEquals('INFO', $adminEntry['level']); | ||||
|          | ||||
|         // Test entry without route
 | ||||
|         $noRouteEntry = array_filter($entries, fn($e) => $e['message'] === 'Info without route'); | ||||
|         $noRouteEntry = array_values($noRouteEntry)[0]; | ||||
|         $this->assertEquals('', $noRouteEntry['route']); | ||||
|     } | ||||
| 
 | ||||
|     public function testGetLogDataWithLevelFilter(): void | ||||
|     { | ||||
|         $logContent = implode("\n", [ | ||||
|             '[2025-01-31 12:00:00] DEBUG: 127.0.0.1 - Debug message', | ||||
|             '[2025-01-31 12:01:00] INFO: 127.0.0.1 - Info message', | ||||
|             '[2025-01-31 12:02:00] ERROR: 127.0.0.1 - Error message' | ||||
|         ]); | ||||
| 
 | ||||
|         file_put_contents($this->testLogFile, $logContent); | ||||
| 
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData('ERROR'); | ||||
|          | ||||
|         // Should only include ERROR entries
 | ||||
|         $this->assertCount(1, $data['logEntries']); | ||||
|         $this->assertEquals('ERROR', $data['logEntries'][0]['level']); | ||||
|         $this->assertEquals('Error message', $data['logEntries'][0]['message']); | ||||
|         $this->assertEquals('ERROR', $data['currentLevelFilter']); | ||||
|     } | ||||
| 
 | ||||
|     public function testGetLogDataWithRouteFilter(): void | ||||
|     { | ||||
|         $logContent = implode("\n", [ | ||||
|             '[2025-01-31 12:00:00] INFO: 127.0.0.1 [GET /] - Home page', | ||||
|             '[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin page', | ||||
|             '[2025-01-31 12:02:00] INFO: 127.0.0.1 [POST /admin] - Admin save' | ||||
|         ]); | ||||
| 
 | ||||
|         file_put_contents($this->testLogFile, $logContent); | ||||
| 
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData('', 'GET /admin'); | ||||
|          | ||||
|         // Should only include GET /admin entries
 | ||||
|         $this->assertCount(1, $data['logEntries']); | ||||
|         $this->assertEquals('GET /admin', $data['logEntries'][0]['route']); | ||||
|         $this->assertEquals('Admin page', $data['logEntries'][0]['message']); | ||||
|         $this->assertEquals('GET /admin', $data['currentRouteFilter']); | ||||
|     } | ||||
| 
 | ||||
|     public function testGetLogDataWithBothFilters(): void | ||||
|     { | ||||
|         $logContent = implode("\n", [ | ||||
|             '[2025-01-31 12:00:00] ERROR: 127.0.0.1 [GET /admin] - Admin error', | ||||
|             '[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin info',  | ||||
|             '[2025-01-31 12:02:00] ERROR: 127.0.0.1 [GET /] - Home error' | ||||
|         ]); | ||||
| 
 | ||||
|         file_put_contents($this->testLogFile, $logContent); | ||||
| 
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData('ERROR', 'GET /admin'); | ||||
|          | ||||
|         // Should only include entries matching both filters
 | ||||
|         $this->assertCount(1, $data['logEntries']); | ||||
|         $this->assertEquals('ERROR', $data['logEntries'][0]['level']); | ||||
|         $this->assertEquals('GET /admin', $data['logEntries'][0]['route']); | ||||
|         $this->assertEquals('Admin error', $data['logEntries'][0]['message']); | ||||
|     } | ||||
| 
 | ||||
|     public function testGetLogDataWithRotatedLogs(): void | ||||
|     { | ||||
|         // Create main log file
 | ||||
|         $mainLogContent = '[2025-01-31 14:00:00] INFO: 127.0.0.1 - Current log entry'; | ||||
|         file_put_contents($this->testLogFile, $mainLogContent); | ||||
| 
 | ||||
|         // Create rotated log files
 | ||||
|         $rotatedLog1 = '[2025-01-31 13:00:00] ERROR: 127.0.0.1 - Rotated log entry 1'; | ||||
|         file_put_contents($this->testLogFile . '.1', $rotatedLog1); | ||||
| 
 | ||||
|         $rotatedLog2 = '[2025-01-31 12:00:00] WARNING: 127.0.0.1 - Rotated log entry 2'; | ||||
|         file_put_contents($this->testLogFile . '.2', $rotatedLog2); | ||||
| 
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
|         // Should read from all log files, newest first
 | ||||
|         $this->assertCount(3, $data['logEntries']); | ||||
|         $this->assertEquals('Current log entry', $data['logEntries'][0]['message']); | ||||
|         $this->assertEquals('Rotated log entry 1', $data['logEntries'][1]['message']); | ||||
|         $this->assertEquals('Rotated log entry 2', $data['logEntries'][2]['message']); | ||||
|     } | ||||
| 
 | ||||
|     public function testGetLogDataExtractsAvailableRoutes(): void | ||||
|     { | ||||
|         $logContent = implode("\n", [ | ||||
|             '[2025-01-31 12:00:00] INFO: 127.0.0.1 [GET /] - Home', | ||||
|             '[2025-01-31 12:01:00] INFO: 127.0.0.1 [GET /admin] - Admin', | ||||
|             '[2025-01-31 12:02:00] INFO: 127.0.0.1 [POST /admin] - Admin post', | ||||
|             '[2025-01-31 12:03:00] INFO: 127.0.0.1 [GET /admin] - Admin again', | ||||
|             '[2025-01-31 12:04:00] INFO: 127.0.0.1 - No route' | ||||
|         ]); | ||||
| 
 | ||||
|         file_put_contents($this->testLogFile, $logContent); | ||||
| 
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
|         // Should extract unique routes, sorted
 | ||||
|         $expectedRoutes = ['GET /', 'GET /admin', 'POST /admin']; | ||||
|         $this->assertEquals($expectedRoutes, $data['availableRoutes']); | ||||
|     } | ||||
| 
 | ||||
|     public function testGetLogDataHandlesInvalidLogLines(): void | ||||
|     { | ||||
|         $logContent = implode("\n", [ | ||||
|             '[2025-01-31 12:00:00] INFO: 127.0.0.1 - Valid entry', | ||||
|             'This is not a valid log line', | ||||
|             'Neither is this one', | ||||
|             '[2025-01-31 12:01:00] ERROR: 127.0.0.1 - Another valid entry' | ||||
|         ]); | ||||
| 
 | ||||
|         file_put_contents($this->testLogFile, $logContent); | ||||
| 
 | ||||
|         $controller = new LogController($this->tempLogDir); | ||||
|         $data = $controller->getLogData(); | ||||
|          | ||||
|         // Should only include valid entries, ignore invalid ones
 | ||||
|         $this->assertCount(2, $data['logEntries']); | ||||
|         $this->assertEquals('Another valid entry', $data['logEntries'][0]['message']); | ||||
|         $this->assertEquals('Valid entry', $data['logEntries'][1]['message']); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										169
									
								
								tests/Framework/Log/LogTest.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										169
									
								
								tests/Framework/Log/LogTest.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,169 @@ | ||||
| <?php | ||||
| use PHPUnit\Framework\TestCase; | ||||
| 
 | ||||
| class LogTest extends TestCase | ||||
| { | ||||
|     private string $tempLogDir; | ||||
|     private string $testLogFile; | ||||
| 
 | ||||
|     protected function setUp(): void | ||||
|     { | ||||
|         // Create a temporary directory for test logs
 | ||||
|         $this->tempLogDir = sys_get_temp_dir() . '/tkr_test_logs_' . uniqid(); | ||||
|         mkdir($this->tempLogDir, 0777, true); | ||||
|          | ||||
|         $this->testLogFile = $this->tempLogDir . '/tkr.log'; | ||||
|          | ||||
|         // Initialize Log with test file and reset route context
 | ||||
|         Log::init($this->testLogFile); | ||||
|         Log::setRouteContext(''); | ||||
|     } | ||||
| 
 | ||||
|     protected function tearDown(): void | ||||
|     { | ||||
|         // Clean up temp directory
 | ||||
|         if (is_dir($this->tempLogDir)) { | ||||
|             $this->deleteDirectory($this->tempLogDir); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     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 testSetRouteContext(): void | ||||
|     { | ||||
|         Log::setRouteContext('GET /admin'); | ||||
|          | ||||
|         // Create a mock config for log level
 | ||||
|         global $config; | ||||
|         $config = new stdClass(); | ||||
|         $config->logLevel = 1; // DEBUG level
 | ||||
|          | ||||
|         Log::debug('Test message'); | ||||
|          | ||||
|         $this->assertFileExists($this->testLogFile); | ||||
|          | ||||
|         $logContent = file_get_contents($this->testLogFile); | ||||
|         $this->assertStringContainsString('[GET /admin]', $logContent); | ||||
|         $this->assertStringContainsString('Test message', $logContent); | ||||
|     } | ||||
| 
 | ||||
|     public function testEmptyRouteContext(): void | ||||
|     { | ||||
|         Log::setRouteContext(''); | ||||
|          | ||||
|         global $config; | ||||
|         $config = new stdClass(); | ||||
|         $config->logLevel = 1; | ||||
|          | ||||
|         Log::info('Test without route'); | ||||
|          | ||||
|         $logContent = file_get_contents($this->testLogFile); | ||||
|          | ||||
|         // Should match format without route context: [timestamp] LEVEL: IP - message
 | ||||
|         $this->assertMatchesRegularExpression( | ||||
|             '/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] INFO: .+ - Test without route/', | ||||
|             $logContent | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public function testLogLevelFiltering(): void | ||||
|     { | ||||
|         global $config; | ||||
|         $config = new stdClass(); | ||||
|         $config->logLevel = 3; // WARNING level
 | ||||
|          | ||||
|         Log::debug('Debug message');   // Should be filtered out
 | ||||
|         Log::info('Info message');     // Should be filtered out  
 | ||||
|         Log::warning('Warning message'); // Should be logged
 | ||||
|         Log::error('Error message');   // Should be logged
 | ||||
|          | ||||
|         $logContent = file_get_contents($this->testLogFile); | ||||
|          | ||||
|         $this->assertStringNotContainsString('Debug message', $logContent); | ||||
|         $this->assertStringNotContainsString('Info message', $logContent); | ||||
|         $this->assertStringContainsString('Warning message', $logContent); | ||||
|         $this->assertStringContainsString('Error message', $logContent); | ||||
|     } | ||||
| 
 | ||||
|     public function testLogMessageFormat(): void | ||||
|     { | ||||
|         Log::setRouteContext('POST /admin'); | ||||
|          | ||||
|         global $config; | ||||
|         $config = new stdClass(); | ||||
|         $config->logLevel = 1; | ||||
|          | ||||
|         Log::error('Test error message'); | ||||
|          | ||||
|         $logContent = file_get_contents($this->testLogFile); | ||||
|          | ||||
|         // Check log format: [timestamp] LEVEL: IP [route] - message
 | ||||
|         $this->assertMatchesRegularExpression( | ||||
|             '/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] ERROR: .+ \[POST \/admin\] - Test error message/', | ||||
|             $logContent | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     public function testInitCreatesLogDirectory(): void | ||||
|     { | ||||
|         $newLogFile = $this->tempLogDir . '/nested/logs/test.log'; | ||||
|          | ||||
|         // Directory doesn't exist yet
 | ||||
|         $this->assertDirectoryDoesNotExist(dirname($newLogFile)); | ||||
|          | ||||
|         Log::init($newLogFile); | ||||
|          | ||||
|         // init() should create the directory
 | ||||
|         $this->assertDirectoryExists(dirname($newLogFile)); | ||||
|     } | ||||
| 
 | ||||
|     public function testLogRotation(): void | ||||
|     { | ||||
|         global $config; | ||||
|         $config = new stdClass(); | ||||
|         $config->logLevel = 1; | ||||
|          | ||||
|         // Create a log file with exactly 1000 lines (the rotation threshold)
 | ||||
|         $logLines = str_repeat("[2025-01-31 12:00:00] INFO: 127.0.0.1 - Test line\n", 1000); | ||||
|         file_put_contents($this->testLogFile, $logLines); | ||||
|          | ||||
|         // This should trigger rotation
 | ||||
|         Log::info('This should trigger rotation'); | ||||
|          | ||||
|         // Original log should be rotated to .1
 | ||||
|         $this->assertFileExists($this->testLogFile . '.1'); | ||||
|          | ||||
|         // New log should contain the new message
 | ||||
|         $newLogContent = file_get_contents($this->testLogFile); | ||||
|         $this->assertStringContainsString('This should trigger rotation', $newLogContent); | ||||
|          | ||||
|         // Rotated log should contain old content
 | ||||
|         $rotatedContent = file_get_contents($this->testLogFile . '.1'); | ||||
|         $this->assertStringContainsString('Test line', $rotatedContent); | ||||
|     } | ||||
| 
 | ||||
|     public function testDefaultLogLevelWhenConfigMissing(): void | ||||
|     { | ||||
|         // Clear global config
 | ||||
|         global $config; | ||||
|         $config = null; | ||||
|          | ||||
|         // Should not throw errors and should default to INFO level
 | ||||
|         Log::debug('Debug message');  // Should be filtered out (default INFO level = 2)
 | ||||
|         Log::info('Info message');    // Should be logged
 | ||||
|          | ||||
|         $logContent = file_get_contents($this->testLogFile); | ||||
|         $this->assertStringNotContainsString('Debug message', $logContent); | ||||
|         $this->assertStringContainsString('Info message', $logContent); | ||||
|     } | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user