- Add comprehensive health check system with multiple endpoints - Add Prometheus metrics endpoint - Add production logging configurations (5 strategies) - Add complete deployment documentation suite: * QUICKSTART.md - 30-minute deployment guide * DEPLOYMENT_CHECKLIST.md - Printable verification checklist * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference * production-logging.md - Logging configuration guide * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation * README.md - Navigation hub * DEPLOYMENT_SUMMARY.md - Executive summary - Add deployment scripts and automation - Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment - Update README with production-ready features All production infrastructure is now complete and ready for deployment.
260 lines
8.7 KiB
PHP
260 lines
8.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Filesystem\ValueObjects\FilePath;
|
|
use App\Framework\Logging\LogLevel;
|
|
use App\Framework\Logging\LogViewer;
|
|
use App\Framework\Logging\ValueObjects\LogName;
|
|
use App\Framework\Logging\ValueObjects\LogReadResult;
|
|
use App\Framework\Logging\ValueObjects\LogSearchResult;
|
|
use App\Framework\Logging\ValueObjects\LogViewerConfig;
|
|
|
|
describe('LogViewer', function () {
|
|
beforeEach(function () {
|
|
// Create test log directory structure
|
|
$this->testDir = '/tmp/logviewer_test_' . uniqid();
|
|
mkdir($this->testDir, 0777, true);
|
|
mkdir($this->testDir . '/app', 0777, true);
|
|
mkdir($this->testDir . '/debug', 0777, true);
|
|
|
|
// Create test log files
|
|
file_put_contents(
|
|
$this->testDir . '/app/error.log',
|
|
"[2024-01-15 10:30:45] local.ERROR: Database connection failed\n" .
|
|
"[2024-01-15 10:31:00] local.WARNING: Cache miss\n" .
|
|
"[2024-01-15 10:31:30] local.INFO: User logged in\n"
|
|
);
|
|
|
|
file_put_contents(
|
|
$this->testDir . '/debug/test.log',
|
|
"[2024-01-15 10:32:00] local.DEBUG: Debug message\n" .
|
|
"[2024-01-15 10:32:30] local.ERROR: Test error\n"
|
|
);
|
|
|
|
// Create config
|
|
$this->config = new LogViewerConfig(
|
|
storageLogsPath: $this->testDir,
|
|
logDirectories: ['app', 'debug'],
|
|
defaultLimit: 100
|
|
);
|
|
|
|
$this->logViewer = new LogViewer($this->config);
|
|
});
|
|
|
|
afterEach(function () {
|
|
// Cleanup test directory
|
|
if (is_dir($this->testDir)) {
|
|
array_map('unlink', glob($this->testDir . '/*/*.log'));
|
|
rmdir($this->testDir . '/app');
|
|
rmdir($this->testDir . '/debug');
|
|
rmdir($this->testDir);
|
|
}
|
|
});
|
|
|
|
it('gets available logs', function () {
|
|
$logs = $this->logViewer->getAvailableLogs();
|
|
|
|
expect($logs)->toBeArray();
|
|
expect($logs)->toHaveKey('app_error');
|
|
expect($logs)->toHaveKey('debug_test');
|
|
|
|
expect($logs['app_error']['name'])->toBeInstanceOf(LogName::class);
|
|
expect($logs['app_error']['path'])->toBeInstanceOf(FilePath::class);
|
|
expect($logs['app_error'])->toHaveKey('size');
|
|
expect($logs['app_error'])->toHaveKey('modified');
|
|
expect($logs['app_error']['readable'])->toBeTrue();
|
|
});
|
|
|
|
it('reads log with default limit', function () {
|
|
$result = $this->logViewer->readLog('app_error');
|
|
|
|
expect($result)->toBeInstanceOf(LogReadResult::class);
|
|
expect($result->entries)->toHaveCount(3);
|
|
expect($result->logName->value)->toBe('app_error');
|
|
});
|
|
|
|
it('reads log with custom limit', function () {
|
|
$result = $this->logViewer->readLog('app_error', limit: 2);
|
|
|
|
expect($result->entries)->toHaveCount(2);
|
|
expect($result->limit)->toBe(2);
|
|
});
|
|
|
|
it('reads log with level filter', function () {
|
|
$result = $this->logViewer->readLog('app_error', level: LogLevel::ERROR);
|
|
|
|
expect($result->entries)->toHaveCount(1);
|
|
expect($result->entries[0]->level)->toBe(LogLevel::ERROR);
|
|
});
|
|
|
|
it('reads log with string level filter', function () {
|
|
$result = $this->logViewer->readLog('app_error', level: 'ERROR');
|
|
|
|
expect($result->entries)->toHaveCount(1);
|
|
expect($result->entries[0]->level)->toBe(LogLevel::ERROR);
|
|
});
|
|
|
|
it('reads log with search filter', function () {
|
|
$result = $this->logViewer->readLog('app_error', search: 'Database');
|
|
|
|
expect($result->entries)->toHaveCount(1);
|
|
expect($result->entries[0]->message)->toContain('Database');
|
|
});
|
|
|
|
it('reads log with combined filters', function () {
|
|
$result = $this->logViewer->readLog(
|
|
'app_error',
|
|
level: LogLevel::ERROR,
|
|
search: 'Database'
|
|
);
|
|
|
|
expect($result->entries)->toHaveCount(1);
|
|
expect($result->entries[0]->level)->toBe(LogLevel::ERROR);
|
|
expect($result->entries[0]->message)->toContain('Database');
|
|
});
|
|
|
|
it('accepts LogName value object', function () {
|
|
$logName = LogName::fromString('app_error');
|
|
|
|
$result = $this->logViewer->readLog($logName);
|
|
|
|
expect($result->entries)->toHaveCount(3);
|
|
});
|
|
|
|
it('throws on non-existent log', function () {
|
|
$this->logViewer->readLog('nonexistent_log');
|
|
})->throws(\InvalidArgumentException::class, "Log 'nonexistent_log' not found");
|
|
|
|
it('tails log with default lines', function () {
|
|
$result = $this->logViewer->tailLog('app_error');
|
|
|
|
expect($result)->toBeInstanceOf(LogReadResult::class);
|
|
expect($result->entries)->toHaveCount(3);
|
|
// Entries should be in reverse order (most recent first)
|
|
expect($result->entries[0]->message)->toContain('User logged in');
|
|
});
|
|
|
|
it('tails log with custom line count', function () {
|
|
$result = $this->logViewer->tailLog('app_error', lines: 2);
|
|
|
|
expect($result->entries)->toHaveCount(2);
|
|
expect($result->limit)->toBe(2);
|
|
});
|
|
|
|
it('searches across multiple logs', function () {
|
|
$result = $this->logViewer->searchLogs('error');
|
|
|
|
expect($result)->toBeInstanceOf(LogSearchResult::class);
|
|
expect($result->totalMatches)->toBeGreaterThan(0);
|
|
expect($result->filesSearched)->toBeGreaterThan(0);
|
|
});
|
|
|
|
it('searches specific logs', function () {
|
|
$result = $this->logViewer->searchLogs(
|
|
'error',
|
|
logNames: ['app_error']
|
|
);
|
|
|
|
expect($result->filesSearched)->toBe(1);
|
|
});
|
|
|
|
it('searches with level filter', function () {
|
|
$result = $this->logViewer->searchLogs(
|
|
'error',
|
|
level: LogLevel::ERROR
|
|
);
|
|
|
|
$allEntries = $result->getAllEntries();
|
|
foreach ($allEntries as $entry) {
|
|
expect($entry->level)->toBe(LogLevel::ERROR);
|
|
}
|
|
});
|
|
|
|
it('streams log entries in batches', function () {
|
|
$batchSize = 2;
|
|
$batches = [];
|
|
|
|
foreach ($this->logViewer->streamLog('app_error', batchSize: $batchSize) as $batch) {
|
|
$batches[] = $batch;
|
|
}
|
|
|
|
expect($batches)->toHaveCount(2); // 3 entries / 2 per batch = 2 batches
|
|
expect($batches[0]['entries_in_batch'])->toBe(2);
|
|
expect($batches[1]['entries_in_batch'])->toBe(1);
|
|
expect($batches[1]['is_final'])->toBeTrue();
|
|
});
|
|
|
|
it('streams log with level filter', function () {
|
|
$batches = [];
|
|
|
|
foreach ($this->logViewer->streamLog('app_error', level: LogLevel::ERROR) as $batch) {
|
|
$batches[] = $batch;
|
|
}
|
|
|
|
$totalEntries = array_sum(array_column($batches, 'entries_in_batch'));
|
|
expect($totalEntries)->toBe(1);
|
|
});
|
|
|
|
it('streams log with search filter', function () {
|
|
$batches = [];
|
|
|
|
foreach ($this->logViewer->streamLog('app_error', search: 'Database') as $batch) {
|
|
$batches[] = $batch;
|
|
}
|
|
|
|
$totalEntries = array_sum(array_column($batches, 'entries_in_batch'));
|
|
expect($totalEntries)->toBe(1);
|
|
});
|
|
|
|
it('handles empty log file', function () {
|
|
// Create empty log file
|
|
file_put_contents($this->testDir . '/app/empty.log', '');
|
|
|
|
$result = $this->logViewer->readLog('app_empty');
|
|
|
|
expect($result->isEmpty())->toBeTrue();
|
|
expect($result->entries)->toBe([]);
|
|
});
|
|
|
|
it('handles log with unstructured lines', function () {
|
|
// Create log with mixed structured and unstructured lines
|
|
file_put_contents(
|
|
$this->testDir . '/app/mixed.log',
|
|
"[2024-01-15 10:30:45] local.ERROR: Structured error\n" .
|
|
"Unstructured log line\n" .
|
|
"Another unstructured line\n"
|
|
);
|
|
|
|
$result = $this->logViewer->readLog('app_mixed');
|
|
|
|
expect($result->entries)->toHaveCount(3);
|
|
expect($result->entries[0]->parsed)->toBeTrue();
|
|
expect($result->entries[1]->parsed)->toBeFalse();
|
|
expect($result->entries[2]->parsed)->toBeFalse();
|
|
});
|
|
|
|
it('handles log with unknown level', function () {
|
|
file_put_contents(
|
|
$this->testDir . '/app/unknown.log',
|
|
"[2024-01-15 10:30:45] local.UNKNOWN: Unknown level\n"
|
|
);
|
|
|
|
$result = $this->logViewer->readLog('app_unknown');
|
|
|
|
expect($result->entries)->toHaveCount(1);
|
|
// Should fallback to INFO for unknown levels
|
|
expect($result->entries[0]->level)->toBe(LogLevel::INFO);
|
|
});
|
|
|
|
it('skips unreadable log files', function () {
|
|
// Create unreadable file (can't actually test this without root permissions)
|
|
// Just verify that getAvailableLogs filters correctly
|
|
$logs = $this->logViewer->getAvailableLogs();
|
|
|
|
foreach ($logs as $log) {
|
|
expect($log['readable'])->toBeTrue();
|
|
}
|
|
});
|
|
});
|