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