feat(Production): Complete production deployment infrastructure

- 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.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\UserContext;
use App\Framework\Logging\ValueObjects\RequestContext;
use App\Framework\Tracing\TraceContext;
use App\Framework\Random\SecureRandomGenerator;
describe('LogContext', function () {
describe('empty()', function () {
it('creates empty LogContext', function () {
$context = LogContext::empty();
expect($context->structured)->toBe([]);
expect($context->tags)->toBe([]);
expect($context->trace)->toBeNull();
expect($context->user)->toBeNull();
expect($context->request)->toBeNull();
expect($context->metadata)->toBe([]);
});
});
describe('withData()', function () {
it('creates LogContext with structured data', function () {
$data = ['user_id' => 123, 'action' => 'login'];
$context = LogContext::withData($data);
expect($context->structured)->toBe($data);
expect($context->tags)->toBe([]);
});
});
describe('withTags()', function () {
it('creates LogContext with tags', function () {
$context = LogContext::withTags('security', 'authentication');
expect($context->tags)->toBe(['security', 'authentication']);
expect($context->structured)->toBe([]);
});
it('accepts variadic tags', function () {
$context = LogContext::withTags('tag1', 'tag2', 'tag3');
expect($context->tags)->toHaveCount(3);
expect($context->tags)->toContain('tag1', 'tag2', 'tag3');
});
});
describe('addData()', function () {
it('adds single data entry', function () {
$context = LogContext::empty()->addData('key', 'value');
expect($context->structured)->toBe(['key' => 'value']);
});
it('preserves existing data when adding new', function () {
$context = LogContext::withData(['existing' => 'data'])
->addData('new', 'value');
expect($context->structured)->toBe([
'existing' => 'data',
'new' => 'value',
]);
});
it('returns new instance (immutability)', function () {
$original = LogContext::withData(['original' => 'data']);
$modified = $original->addData('new', 'value');
expect($original->structured)->toBe(['original' => 'data']);
expect($modified->structured)->toBe([
'original' => 'data',
'new' => 'value',
]);
expect($original)->not->toBe($modified);
});
});
describe('mergeData()', function () {
it('merges multiple data entries', function () {
$context = LogContext::withData(['existing' => 'data'])
->mergeData(['new1' => 'value1', 'new2' => 'value2']);
expect($context->structured)->toBe([
'existing' => 'data',
'new1' => 'value1',
'new2' => 'value2',
]);
});
it('overwrites existing keys', function () {
$context = LogContext::withData(['key' => 'old'])
->mergeData(['key' => 'new']);
expect($context->structured['key'])->toBe('new');
});
});
describe('addTags()', function () {
it('adds tags to existing tags', function () {
$context = LogContext::withTags('tag1')
->addTags('tag2', 'tag3');
expect($context->tags)->toHaveCount(3);
expect($context->tags)->toContain('tag1', 'tag2', 'tag3');
});
it('removes duplicate tags', function () {
$context = LogContext::withTags('tag1', 'tag2')
->addTags('tag2', 'tag3');
expect($context->tags)->toHaveCount(3);
expect($context->tags)->toContain('tag1', 'tag2', 'tag3');
});
it('returns new instance (immutability)', function () {
$original = LogContext::withTags('tag1');
$modified = $original->addTags('tag2');
expect($original->tags)->toBe(['tag1']);
expect($modified->tags)->toHaveCount(2);
expect($original)->not->toBe($modified);
});
});
describe('withTrace()', function () {
it('sets trace context', function () {
$random = new SecureRandomGenerator();
$trace = TraceContext::start($random, 'trace-id-123');
$context = LogContext::empty()->withTrace($trace);
expect($context->trace)->toBe($trace);
// Cleanup
TraceContext::clear();
});
});
describe('withUser()', function () {
it('sets user context', function () {
$user = new UserContext('user-123', 'john@example.com');
$context = LogContext::empty()->withUser($user);
expect($context->user)->toBe($user);
});
});
describe('withRequest()', function () {
it('sets request context', function () {
$request = RequestContext::empty();
$context = LogContext::empty()->withRequest($request);
expect($context->request)->toBe($request);
});
});
describe('addMetadata()', function () {
it('adds metadata entry', function () {
$context = LogContext::empty()->addMetadata('key', 'value');
expect($context->metadata)->toBe(['key' => 'value']);
});
it('preserves existing metadata', function () {
$context = LogContext::empty()
->addMetadata('key1', 'value1')
->addMetadata('key2', 'value2');
expect($context->metadata)->toBe([
'key1' => 'value1',
'key2' => 'value2',
]);
});
});
describe('merge()', function () {
it('merges two LogContexts', function () {
$context1 = LogContext::withData(['key1' => 'value1'])
->addTags('tag1');
$context2 = LogContext::withData(['key2' => 'value2'])
->addTags('tag2');
$merged = $context1->merge($context2);
expect($merged->structured)->toBe([
'key1' => 'value1',
'key2' => 'value2',
]);
expect($merged->tags)->toContain('tag1', 'tag2');
});
it('overwrites structured data on merge', function () {
$context1 = LogContext::withData(['key' => 'old']);
$context2 = LogContext::withData(['key' => 'new']);
$merged = $context1->merge($context2);
expect($merged->structured['key'])->toBe('new');
});
it('removes duplicate tags on merge', function () {
$context1 = LogContext::withTags('tag1', 'tag2');
$context2 = LogContext::withTags('tag2', 'tag3');
$merged = $context1->merge($context2);
expect($merged->tags)->toHaveCount(3);
expect($merged->tags)->toContain('tag1', 'tag2', 'tag3');
});
it('prefers other context for trace/user/request', function () {
$random = new SecureRandomGenerator();
$trace1 = TraceContext::start($random, 'trace-1');
TraceContext::clear();
$trace2 = TraceContext::start($random, 'trace-2');
$user1 = new UserContext('user-1', 'user1@example.com');
$user2 = new UserContext('user-2', 'user2@example.com');
$context1 = LogContext::empty()
->withTrace($trace1)
->withUser($user1);
$context2 = LogContext::empty()
->withTrace($trace2)
->withUser($user2);
$merged = $context1->merge($context2);
expect($merged->trace)->toBe($trace2);
expect($merged->user)->toBe($user2);
// Cleanup
TraceContext::clear();
});
it('keeps original trace/user/request when other is null', function () {
$random = new SecureRandomGenerator();
$trace = TraceContext::start($random, 'trace-1');
$user = new UserContext('user-1', 'user1@example.com');
$context1 = LogContext::empty()
->withTrace($trace)
->withUser($user);
$context2 = LogContext::withData(['key' => 'value']);
$merged = $context1->merge($context2);
expect($merged->trace)->toBe($trace);
expect($merged->user)->toBe($user);
// Cleanup
TraceContext::clear();
});
});
describe('hasStructuredData()', function () {
it('returns true when structured data exists', function () {
$context = LogContext::withData(['key' => 'value']);
expect($context->hasStructuredData())->toBeTrue();
});
it('returns false when no structured data', function () {
$context = LogContext::empty();
expect($context->hasStructuredData())->toBeFalse();
});
});
describe('hasTags()', function () {
it('returns true when tags exist', function () {
$context = LogContext::withTags('tag1');
expect($context->hasTags())->toBeTrue();
});
it('returns false when no tags', function () {
$context = LogContext::empty();
expect($context->hasTags())->toBeFalse();
});
});
describe('hasTag()', function () {
it('returns true when specific tag exists', function () {
$context = LogContext::withTags('security', 'auth');
expect($context->hasTag('security'))->toBeTrue();
expect($context->hasTag('auth'))->toBeTrue();
});
it('returns false when tag does not exist', function () {
$context = LogContext::withTags('security');
expect($context->hasTag('performance'))->toBeFalse();
});
it('uses strict comparison', function () {
$context = LogContext::withTags('123');
expect($context->hasTag('123'))->toBeTrue();
// hasTag expects string, so we don't test with int
});
});
describe('toArray()', function () {
it('returns empty array for empty context', function () {
$context = LogContext::empty();
expect($context->toArray())->toBe([]);
});
it('includes structured data when present', function () {
$context = LogContext::withData(['key' => 'value']);
$array = $context->toArray();
expect($array)->toHaveKey('structured');
expect($array['structured'])->toBe(['key' => 'value']);
});
it('includes tags when present', function () {
$context = LogContext::withTags('tag1', 'tag2');
$array = $context->toArray();
expect($array)->toHaveKey('tags');
expect($array['tags'])->toBe(['tag1', 'tag2']);
});
it('includes trace when present', function () {
$random = new SecureRandomGenerator();
$trace = TraceContext::start($random, 'trace-123');
$context = LogContext::empty()->withTrace($trace);
$array = $context->toArray();
expect($array)->toHaveKey('trace');
expect($array['trace'])->toHaveKey('trace_id');
expect($array['trace']['trace_id'])->toBe('trace-123');
// Cleanup
TraceContext::clear();
});
it('includes user when present', function () {
$user = new UserContext('user-123', 'john@example.com');
$context = LogContext::empty()->withUser($user);
$array = $context->toArray();
expect($array)->toHaveKey('user');
expect($array['user'])->toBeArray();
});
it('includes request when present', function () {
$request = RequestContext::empty();
$context = LogContext::empty()->withRequest($request);
$array = $context->toArray();
expect($array)->toHaveKey('request');
expect($array['request'])->toBeArray();
});
it('includes metadata when present', function () {
$context = LogContext::empty()->addMetadata('key', 'value');
$array = $context->toArray();
expect($array)->toHaveKey('metadata');
expect($array['metadata'])->toBe(['key' => 'value']);
});
it('includes all sections when all are present', function () {
$random = new SecureRandomGenerator();
$trace = TraceContext::start($random, 'trace-123');
$user = new UserContext('user-123', 'john@example.com');
$request = RequestContext::empty();
$context = LogContext::withData(['key' => 'value'])
->addTags('security')
->withTrace($trace)
->withUser($user)
->withRequest($request)
->addMetadata('meta', 'data');
$array = $context->toArray();
expect($array)->toHaveKeys(['structured', 'tags', 'trace', 'user', 'request', 'metadata']);
// Cleanup
TraceContext::clear();
});
});
describe('immutability', function () {
it('is immutable through fluent API', function () {
$original = LogContext::empty();
$modified = $original
->addData('key', 'value')
->addTags('tag')
->addMetadata('meta', 'value');
// Original remains unchanged
expect($original->structured)->toBe([]);
expect($original->tags)->toBe([]);
expect($original->metadata)->toBe([]);
// Modified has all changes
expect($modified->structured)->toBe(['key' => 'value']);
expect($modified->tags)->toBe(['tag']);
expect($modified->metadata)->toBe(['meta' => 'value']);
});
});
});

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ValueObjects\LogEntry;
describe('LogEntry', function () {
it('creates from parsed log line', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'ERROR',
messageWithContext: 'Test error message {"user_id":123}',
raw: '[2024-01-15 10:30:45] local.ERROR: Test error message {"user_id":123}',
sourcePath: $path
);
expect($entry->level)->toBe(LogLevel::ERROR);
expect($entry->message)->toBe('Test error message');
expect($entry->context)->toBe('{"user_id":123}');
expect($entry->parsed)->toBeTrue();
expect($entry->sourcePath)->toBe($path);
});
it('creates from raw unparsed line', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromRawLine('Some random log line', $path);
expect($entry->level)->toBe(LogLevel::INFO);
expect($entry->message)->toBe('Some random log line');
expect($entry->context)->toBe('');
expect($entry->parsed)->toBeFalse();
});
it('extracts message without context', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'INFO',
messageWithContext: 'Simple message without context',
raw: 'Simple message without context',
sourcePath: $path
);
expect($entry->message)->toBe('Simple message without context');
expect($entry->context)->toBe('');
});
it('matches search term in message', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'ERROR',
messageWithContext: 'Database connection failed',
raw: 'Database connection failed',
sourcePath: $path
);
expect($entry->matchesSearch('database'))->toBeTrue();
expect($entry->matchesSearch('Database'))->toBeTrue(); // Case insensitive
expect($entry->matchesSearch('connection'))->toBeTrue();
expect($entry->matchesSearch('redis'))->toBeFalse();
});
it('matches search term in context', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'ERROR',
messageWithContext: 'Error occurred {"database":"mysql"}',
raw: 'Error occurred {"database":"mysql"}',
sourcePath: $path
);
expect($entry->matchesSearch('mysql'))->toBeTrue();
});
it('checks log level match', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'ERROR',
messageWithContext: 'Test message',
raw: 'Test message',
sourcePath: $path
);
expect($entry->matchesLevel(LogLevel::ERROR))->toBeTrue();
expect($entry->matchesLevel(LogLevel::INFO))->toBeFalse();
});
it('checks minimum log level', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'ERROR',
messageWithContext: 'Test message',
raw: 'Test message',
sourcePath: $path
);
expect($entry->isAtLeastLevel(LogLevel::ERROR))->toBeTrue();
expect($entry->isAtLeastLevel(LogLevel::WARNING))->toBeTrue();
expect($entry->isAtLeastLevel(LogLevel::CRITICAL))->toBeFalse();
});
it('formats timestamp', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'INFO',
messageWithContext: 'Test',
raw: 'Test',
sourcePath: $path
);
expect($entry->getFormattedTimestamp())->toBe('2024-01-15 10:30:45');
expect($entry->getFormattedTimestamp('Y-m-d'))->toBe('2024-01-15');
});
it('parses context as array', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'ERROR',
messageWithContext: 'Error {"user_id":123,"action":"login"}',
raw: 'Error',
sourcePath: $path
);
$contextArray = $entry->getContextArray();
expect($contextArray)->toBeArray();
expect($contextArray['user_id'])->toBe(123);
expect($contextArray['action'])->toBe('login');
});
it('returns empty array for no context', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'INFO',
messageWithContext: 'Simple message',
raw: 'Simple message',
sourcePath: $path
);
expect($entry->getContextArray())->toBe([]);
expect($entry->hasContext())->toBeFalse();
});
it('converts to array', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'ERROR',
messageWithContext: 'Test error',
raw: '[2024-01-15 10:30:45] ERROR: Test error',
sourcePath: $path
);
$array = $entry->toArray();
expect($array)->toHaveKey('timestamp');
expect($array)->toHaveKey('level');
expect($array)->toHaveKey('message');
expect($array)->toHaveKey('context');
expect($array)->toHaveKey('raw');
expect($array)->toHaveKey('parsed');
expect($array)->toHaveKey('source_path');
expect($array['level'])->toBe('ERROR');
expect($array['message'])->toBe('Test error');
});
it('parses all log levels correctly', function () {
$path = FilePath::create('/tmp/test.log');
$levels = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY'];
foreach ($levels as $levelName) {
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: $levelName,
messageWithContext: 'Test',
raw: 'Test',
sourcePath: $path
);
expect($entry->level->getName())->toBe($levelName);
}
});
it('handles unknown log level gracefully', function () {
$path = FilePath::create('/tmp/test.log');
$entry = LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'UNKNOWN',
messageWithContext: 'Test',
raw: 'Test',
sourcePath: $path
);
// Should fallback to INFO
expect($entry->level)->toBe(LogLevel::INFO);
});
});

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Framework\Logging\ValueObjects\LogName;
describe('LogName', function () {
it('creates valid log name', function () {
$logName = LogName::fromString('app_error');
expect($logName->value)->toBe('app_error');
expect((string) $logName)->toBe('app_error');
});
it('validates alphanumeric with underscore and hyphen', function () {
$validNames = ['app_error', 'debug-log', 'security_2024', 'LOG-123'];
foreach ($validNames as $name) {
$logName = LogName::fromString($name);
expect($logName->value)->toBe($name);
}
});
it('throws on empty log name', function () {
LogName::fromString('');
})->throws(\InvalidArgumentException::class, 'Log name cannot be empty');
it('throws on invalid characters', function () {
LogName::fromString('app/error');
})->throws(\InvalidArgumentException::class, 'contains invalid characters');
it('throws on log name too long', function () {
$longName = str_repeat('a', 101);
LogName::fromString($longName);
})->throws(\InvalidArgumentException::class, 'too long');
it('extracts subdirectory from log name', function () {
$logName = LogName::fromString('app_error');
expect($logName->getSubdirectory())->toBe('app');
});
it('returns null subdirectory for simple names', function () {
$logName = LogName::fromString('error');
expect($logName->getSubdirectory())->toBeNull();
});
it('extracts filename from log name', function () {
$logName = LogName::fromString('app_error');
expect($logName->getFilename())->toBe('error');
});
it('returns full value as filename for simple names', function () {
$logName = LogName::fromString('error');
expect($logName->getFilename())->toBe('error');
});
it('compares equality correctly', function () {
$logName1 = LogName::fromString('app_error');
$logName2 = LogName::fromString('app_error');
$logName3 = LogName::fromString('debug_log');
expect($logName1->equals($logName2))->toBeTrue();
expect($logName1->equals($logName3))->toBeFalse();
});
});

View File

@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ValueObjects\LogEntry;
use App\Framework\Logging\ValueObjects\LogName;
use App\Framework\Logging\ValueObjects\LogReadResult;
describe('LogReadResult', function () {
beforeEach(function () {
$this->logName = LogName::fromString('app_error');
$this->logPath = FilePath::create('/tmp/test.log');
$this->entries = [
LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'ERROR',
messageWithContext: 'Database error',
raw: '[2024-01-15 10:30:45] local.ERROR: Database error',
sourcePath: $this->logPath
),
LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:31:00',
level: 'WARNING',
messageWithContext: 'Cache miss',
raw: '[2024-01-15 10:31:00] local.WARNING: Cache miss',
sourcePath: $this->logPath
),
LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:31:30',
level: 'INFO',
messageWithContext: 'User logged in',
raw: '[2024-01-15 10:31:30] local.INFO: User logged in',
sourcePath: $this->logPath
),
];
});
it('creates from entries', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
expect($result->logName)->toBe($this->logName);
expect($result->logPath)->toBe($this->logPath);
expect($result->entries)->toHaveCount(3);
expect($result->totalEntries)->toBe(3);
expect($result->limit)->toBe(100);
});
it('checks if empty', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: [],
limit: 100
);
expect($result->isEmpty())->toBeTrue();
expect($result->hasEntries())->toBeFalse();
});
it('filters by log level', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$filtered = $result->filterByLevel(LogLevel::ERROR);
expect($filtered->entries)->toHaveCount(1);
expect($filtered->entries[0]->level)->toBe(LogLevel::ERROR);
});
it('filters by search term', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$filtered = $result->filterBySearch('database');
expect($filtered->entries)->toHaveCount(1);
expect($filtered->entries[0]->message)->toContain('Database');
});
it('filters by minimum log level', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$filtered = $result->filterByMinimumLevel(LogLevel::WARNING);
expect($filtered->entries)->toHaveCount(2); // ERROR and WARNING
expect($filtered->first()->level)->toBe(LogLevel::ERROR);
expect($filtered->last()->level)->toBe(LogLevel::WARNING);
});
it('gets first entry', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$first = $result->first();
expect($first)->not->toBeNull();
expect($first->level)->toBe(LogLevel::ERROR);
});
it('gets last entry', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$last = $result->last();
expect($last)->not->toBeNull();
expect($last->level)->toBe(LogLevel::INFO);
});
it('returns null for first/last on empty result', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: [],
limit: 100
);
expect($result->first())->toBeNull();
expect($result->last())->toBeNull();
});
it('takes limited entries', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$limited = $result->take(2);
expect($limited->entries)->toHaveCount(2);
expect($limited->totalEntries)->toBe(2);
});
it('skips entries', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$skipped = $result->skip(1);
expect($skipped->entries)->toHaveCount(2);
expect($skipped->first()->level)->toBe(LogLevel::WARNING);
});
it('reverses entry order', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$reversed = $result->reverse();
expect($reversed->first()->level)->toBe(LogLevel::INFO);
expect($reversed->last()->level)->toBe(LogLevel::ERROR);
});
it('gets level statistics', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$stats = $result->getLevelStatistics();
expect($stats)->toBeArray();
expect($stats['ERROR'])->toBe(1);
expect($stats['WARNING'])->toBe(1);
expect($stats['INFO'])->toBe(1);
});
it('gets metadata', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100,
search: 'test',
levelFilter: LogLevel::ERROR
);
$metadata = $result->getMetadata();
expect($metadata)->toBeArray();
expect($metadata['log_name'])->toBe('app_error');
expect($metadata['total_entries'])->toBe(3);
expect($metadata['limit'])->toBe(100);
expect($metadata['search'])->toBe('test');
expect($metadata['level_filter'])->toBe('ERROR');
});
it('converts to array', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$array = $result->toArray();
expect($array)->toHaveKey('log_name');
expect($array)->toHaveKey('log_path');
expect($array)->toHaveKey('entries');
expect($array)->toHaveKey('total_entries');
expect($array)->toHaveKey('metadata');
expect($array['entries'])->toBeArray();
expect($array['entries'])->toHaveCount(3);
});
it('creates iterator', function () {
$result = LogReadResult::fromEntries(
logName: $this->logName,
logPath: $this->logPath,
entries: $this->entries,
limit: 100
);
$iterator = $result->getIterator();
expect($iterator)->toBeInstanceOf(\ArrayIterator::class);
$count = 0;
foreach ($iterator as $entry) {
expect($entry)->toBeInstanceOf(LogEntry::class);
$count++;
}
expect($count)->toBe(3);
});
});

View File

@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
use App\Framework\Filesystem\ValueObjects\FilePath;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ValueObjects\LogEntry;
use App\Framework\Logging\ValueObjects\LogName;
use App\Framework\Logging\ValueObjects\LogReadResult;
use App\Framework\Logging\ValueObjects\LogSearchResult;
describe('LogSearchResult', function () {
beforeEach(function () {
$this->logPath1 = FilePath::create('/tmp/app.log');
$this->logPath2 = FilePath::create('/tmp/debug.log');
$this->result1 = LogReadResult::fromEntries(
logName: LogName::fromString('app_error'),
logPath: $this->logPath1,
entries: [
LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:30:45',
level: 'ERROR',
messageWithContext: 'Database connection failed',
raw: '[2024-01-15 10:30:45] local.ERROR: Database connection failed',
sourcePath: $this->logPath1
),
LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:31:00',
level: 'WARNING',
messageWithContext: 'Database slow query',
raw: '[2024-01-15 10:31:00] local.WARNING: Database slow query',
sourcePath: $this->logPath1
),
],
limit: 100
);
$this->result2 = LogReadResult::fromEntries(
logName: LogName::fromString('debug_log'),
logPath: $this->logPath2,
entries: [
LogEntry::fromParsedLine(
timestamp: '2024-01-15 10:32:00',
level: 'INFO',
messageWithContext: 'Database query executed',
raw: '[2024-01-15 10:32:00] local.INFO: Database query executed',
sourcePath: $this->logPath2
),
],
limit: 100
);
});
it('creates from results', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
expect($searchResult->searchTerm)->toBe('database');
expect($searchResult->results)->toHaveCount(2);
expect($searchResult->totalMatches)->toBe(3);
expect($searchResult->filesSearched)->toBe(2);
});
it('creates empty result', function () {
$searchResult = LogSearchResult::empty('test');
expect($searchResult->searchTerm)->toBe('test');
expect($searchResult->results)->toBe([]);
expect($searchResult->totalMatches)->toBe(0);
expect($searchResult->filesSearched)->toBe(0);
expect($searchResult->isEmpty())->toBeTrue();
});
it('checks for matches', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
expect($searchResult->hasMatches())->toBeTrue();
expect($searchResult->isEmpty())->toBeFalse();
});
it('gets all entries', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$allEntries = $searchResult->getAllEntries();
expect($allEntries)->toHaveCount(3);
expect($allEntries[0])->toBeInstanceOf(LogEntry::class);
});
it('filters by log name', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$filtered = $searchResult->filterByLogName(LogName::fromString('app_error'));
expect($filtered->results)->toHaveCount(1);
expect($filtered->totalMatches)->toBe(2);
});
it('filters by minimum level', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$filtered = $searchResult->filterByMinimumLevel(LogLevel::WARNING);
expect($filtered->totalMatches)->toBe(2); // ERROR and WARNING only
});
it('sorts by most recent', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$sorted = $searchResult->sortByMostRecent();
expect($sorted->results)->toHaveCount(2);
// Most recent should be first
expect($sorted->results[0]->last()->message)->toContain('executed');
});
it('takes limited results', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$limited = $searchResult->take(1);
expect($limited->results)->toHaveCount(1);
expect($limited->totalMatches)->toBe(2);
expect($limited->filesSearched)->toBe(1);
});
it('groups by level', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$grouped = $searchResult->groupByLevel();
expect($grouped)->toBeArray();
expect($grouped)->toHaveKey('ERROR');
expect($grouped)->toHaveKey('WARNING');
expect($grouped)->toHaveKey('INFO');
expect($grouped['ERROR'])->toHaveCount(1);
expect($grouped['WARNING'])->toHaveCount(1);
expect($grouped['INFO'])->toHaveCount(1);
});
it('gets statistics', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$stats = $searchResult->getStatistics();
expect($stats)->toBeArray();
expect($stats)->toHaveKey('search_term');
expect($stats)->toHaveKey('total_matches');
expect($stats)->toHaveKey('files_searched');
expect($stats)->toHaveKey('level_distribution');
expect($stats)->toHaveKey('files_with_matches');
expect($stats['search_term'])->toBe('database');
expect($stats['total_matches'])->toBe(3);
expect($stats['files_searched'])->toBe(2);
expect($stats['level_distribution'])->toBeArray();
});
it('converts to array', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$array = $searchResult->toArray();
expect($array)->toBeArray();
expect($array)->toHaveKey('search_term');
expect($array)->toHaveKey('results');
expect($array)->toHaveKey('total_matches');
expect($array)->toHaveKey('files_searched');
expect($array)->toHaveKey('statistics');
expect($array['results'])->toBeArray();
expect($array['results'])->toHaveCount(2);
});
it('gets summary', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$summary = $searchResult->getSummary();
expect($summary)->toBeString();
expect($summary)->toContain('database');
expect($summary)->toContain('3');
expect($summary)->toContain('2');
});
it('gets empty summary', function () {
$searchResult = LogSearchResult::empty('test');
$summary = $searchResult->getSummary();
expect($summary)->toBeString();
expect($summary)->toContain('No matches');
expect($summary)->toContain('test');
});
it('creates iterator', function () {
$searchResult = LogSearchResult::fromResults(
'database',
[$this->result1, $this->result2]
);
$iterator = $searchResult->getIterator();
expect($iterator)->toBeInstanceOf(\ArrayIterator::class);
$count = 0;
foreach ($iterator as $result) {
expect($result)->toBeInstanceOf(LogReadResult::class);
$count++;
}
expect($count)->toBe(2);
});
it('validates results array contains only LogReadResult', function () {
new LogSearchResult(
searchTerm: 'test',
results: ['invalid'],
totalMatches: 0,
filesSearched: 0
);
})->throws(\InvalidArgumentException::class, 'All results must be LogReadResult instances');
});

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ValueObjects\LogViewerConfig;
describe('LogViewerConfig', function () {
it('creates valid config', function () {
$config = new LogViewerConfig(
storageLogsPath: '/var/www/html/storage/logs',
logDirectories: ['app', 'debug'],
defaultLimit: 100
);
expect($config->storageLogsPath)->toBe('/var/www/html/storage/logs');
expect($config->logDirectories)->toBe(['app', 'debug']);
expect($config->defaultLimit)->toBe(100);
expect($config->logLevels)->toBeArray();
});
it('creates default config', function () {
$config = LogViewerConfig::createDefault();
expect($config->storageLogsPath)->toBe('/var/www/html/storage/logs');
expect($config->logDirectories)->toContain('app');
expect($config->logDirectories)->toContain('debug');
expect($config->logDirectories)->toContain('security');
expect($config->defaultLimit)->toBe(100);
});
it('creates development config', function () {
$config = LogViewerConfig::createForDevelopment();
expect($config->storageLogsPath)->toBe('/var/www/html/storage/logs');
expect($config->logDirectories)->toContain('performance');
expect($config->defaultLimit)->toBe(500);
expect($config->logLevels)->toHaveCount(8); // All log levels
});
it('throws on invalid default limit', function () {
new LogViewerConfig(
storageLogsPath: '/var/www/html/storage/logs',
logDirectories: ['app'],
defaultLimit: 0
);
})->throws(\InvalidArgumentException::class, 'Default limit must be at least 1');
it('throws on empty log directories', function () {
new LogViewerConfig(
storageLogsPath: '/var/www/html/storage/logs',
logDirectories: [],
defaultLimit: 100
);
})->throws(\InvalidArgumentException::class, 'At least one log directory must be specified');
it('throws on empty log levels', function () {
new LogViewerConfig(
storageLogsPath: '/var/www/html/storage/logs',
logDirectories: ['app'],
defaultLimit: 100,
logLevels: []
);
})->throws(\InvalidArgumentException::class, 'At least one log level must be specified');
it('builds subdirectory path', function () {
$config = LogViewerConfig::createDefault();
$subdirPath = $config->getSubdirectoryPath('app');
expect($subdirPath)->toBe('/var/www/html/storage/logs/app');
});
it('checks log level existence', function () {
$config = LogViewerConfig::createDefault();
expect($config->hasLogLevel(LogLevel::ERROR))->toBeTrue();
expect($config->hasLogLevel(LogLevel::DEBUG))->toBeTrue();
});
it('gets log level names', function () {
$config = LogViewerConfig::createDefault();
$levelNames = $config->getLogLevelNames();
expect($levelNames)->toBeArray();
expect($levelNames)->toContain('ERROR');
expect($levelNames)->toContain('DEBUG');
expect($levelNames)->toContain('INFO');
});
it('accepts custom log levels', function () {
$config = new LogViewerConfig(
storageLogsPath: '/var/www/html/storage/logs',
logDirectories: ['app'],
defaultLimit: 100,
logLevels: [LogLevel::ERROR, LogLevel::CRITICAL]
);
expect($config->hasLogLevel(LogLevel::ERROR))->toBeTrue();
expect($config->hasLogLevel(LogLevel::CRITICAL))->toBeTrue();
expect($config->hasLogLevel(LogLevel::DEBUG))->toBeFalse();
});
});