docs: consolidate documentation into organized structure

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

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

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

View File

@@ -0,0 +1,293 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Logging;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\LogHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\LogRecord;
use App\Framework\Logging\ProcessorManager;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\RequestContext;
use App\Framework\Logging\ValueObjects\UserContext;
use App\Framework\Random\SecureRandomGenerator;
use App\Framework\Tracing\TraceContext;
describe('DefaultLogger', function () {
beforeEach(function () {
// Clear any existing trace context
TraceContext::clear();
});
afterEach(function () {
TraceContext::clear();
});
it('can log with array context (legacy)', function () {
$handler = new TestLogHandler();
$logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$handler]
);
$context = ['user_id' => 123, 'action' => 'login'];
$logger->info('User logged in', $context);
expect($handler->records)->toHaveCount(1);
$record = $handler->records[0];
expect($record->getMessage())->toBe('User logged in');
expect($record->getContext())->toBe($context);
expect($record->getLevel())->toBe(LogLevel::INFO);
});
it('can log with LogContext', function () {
$handler = new TestLogHandler();
$logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$handler]
);
$logContext = LogContext::withData(['user_id' => 123, 'action' => 'login'])
->addTags('authentication', 'user-action')
->addMetadata('source', 'test');
$logger->info('User logged in', $logContext);
expect($handler->records)->toHaveCount(1);
$record = $handler->records[0];
expect($record->getMessage())->toBe('User logged in');
expect($record->getLevel())->toBe(LogLevel::INFO);
// Context should be converted to array
$context = $record->getContext();
expect($context)->toHaveKey('user_id', 123);
expect($context)->toHaveKey('action', 'login');
expect($context)->toHaveKey('_tags', ['authentication', 'user-action']);
expect($context)->toHaveKey('source', 'test');
// Structured data should be in extras
expect($record->getExtra('structured_tags'))->toBe(['authentication', 'user-action']);
});
it('converts LogContext with trace to array context', function () {
$handler = new TestLogHandler();
$logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$handler]
);
$trace = TraceContext::start(new SecureRandomGenerator());
$span = $trace->startSpan('test-span');
$logContext = LogContext::withData(['key' => 'value'])
->withTrace($trace);
$logger->info('Test message', $logContext);
$record = $handler->records[0];
$context = $record->getContext();
expect($context)->toHaveKey('_trace_id', $trace->getTraceId());
expect($context)->toHaveKey('_span_id', $span->spanId);
// Trace should also be in extras
$traceExtra = $record->getExtra('trace_context');
expect($traceExtra)->toHaveKey('trace_id', $trace->getTraceId());
expect($traceExtra)->toHaveKey('active_span');
});
it('converts LogContext with user to array context', function () {
$handler = new TestLogHandler();
$logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$handler]
);
$userContext = UserContext::authenticated('123', 'john');
$logContext = LogContext::withData(['key' => 'value'])
->withUser($userContext);
$logger->info('Test message', $logContext);
$record = $handler->records[0];
$context = $record->getContext();
expect($context)->toHaveKey('_user_id', '123');
// User should also be in extras
$userExtra = $record->getExtra('user_context');
expect($userExtra)->toBeArray();
expect($userExtra)->toHaveKey('user_id', '123');
});
it('converts LogContext with request to array context', function () {
$handler = new TestLogHandler();
$logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$handler]
);
$requestContext = RequestContext::empty();
$logContext = LogContext::withData(['key' => 'value'])
->withRequest($requestContext);
$logger->info('Test message', $logContext);
$record = $handler->records[0];
// Request should be in extras
$requestExtra = $record->getExtra('request_context');
expect($requestExtra)->toBeArray();
});
it('logs at different levels with LogContext', function () {
$handler = new TestLogHandler();
$logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$handler]
);
$context = LogContext::withData(['test' => 'value']);
$logger->debug('Debug message', $context);
$logger->info('Info message', $context);
$logger->notice('Notice message', $context);
$logger->warning('Warning message', $context);
$logger->error('Error message', $context);
$logger->critical('Critical message', $context);
$logger->alert('Alert message', $context);
$logger->emergency('Emergency message', $context);
expect($handler->records)->toHaveCount(8);
$levels = array_map(fn ($record) => $record->getLevel(), $handler->records);
expect($levels)->toBe([
LogLevel::DEBUG,
LogLevel::INFO,
LogLevel::NOTICE,
LogLevel::WARNING,
LogLevel::ERROR,
LogLevel::CRITICAL,
LogLevel::ALERT,
LogLevel::EMERGENCY,
]);
});
it('respects minimum log level', function () {
$handler = new TestLogHandler();
$logger = new DefaultLogger(
minLevel: LogLevel::WARNING,
handlers: [$handler]
);
$context = LogContext::withData(['test' => 'value']);
$logger->debug('Debug message', $context);
$logger->info('Info message', $context);
$logger->warning('Warning message', $context);
$logger->error('Error message', $context);
expect($handler->records)->toHaveCount(2);
expect($handler->records[0]->getLevel())->toBe(LogLevel::WARNING);
expect($handler->records[1]->getLevel())->toBe(LogLevel::ERROR);
});
it('calls processors on enriched record', function () {
$processor = new TestLogProcessor();
$processorManager = new ProcessorManager($processor);
$handler = new TestLogHandler();
$logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$handler],
processorManager: $processorManager
);
$context = LogContext::withData(['test' => 'value']);
$logger->info('Test message', $context);
expect($processor->processedRecords)->toHaveCount(1);
expect($handler->records)->toHaveCount(1);
// Processor should have seen the enriched record
$processedRecord = $processor->processedRecords[0];
expect($processedRecord->getExtra('structured_tags'))->toBeNull(); // No tags in this test
});
it('handles empty LogContext', function () {
$handler = new TestLogHandler();
$logger = new DefaultLogger(
minLevel: LogLevel::DEBUG,
handlers: [$handler]
);
$logger->info('Test message', LogContext::empty());
expect($handler->records)->toHaveCount(1);
$record = $handler->records[0];
expect($record->getContext())->toBe([]);
expect($record->getExtras())->toBe([]);
});
it('can get configuration', function () {
$handler = new TestLogHandler();
$processor = new TestLogProcessor();
$processorManager = new ProcessorManager($processor);
$logger = new DefaultLogger(
minLevel: LogLevel::INFO,
handlers: [$handler],
processorManager: $processorManager
);
$config = $logger->getConfiguration();
expect($config)->toHaveKey('minLevel', 200);
expect($config)->toHaveKey('handlers');
expect($config)->toHaveKey('processors');
expect($config['handlers'])->toBe([TestLogHandler::class]);
});
});
// Test helpers
class TestLogHandler implements LogHandler
{
public array $records = [];
public function isHandling(LogRecord $record): bool
{
return true;
}
public function handle(LogRecord $record): void
{
$this->records[] = $record;
}
}
class TestLogProcessor implements \App\Framework\Logging\LogProcessor
{
public array $processedRecords = [];
public function processRecord(LogRecord $record): LogRecord
{
$this->processedRecords[] = $record;
return $record->addExtra('processed_by', 'TestLogProcessor');
}
public function getPriority(): int
{
return 100;
}
public function getName(): string
{
return 'test-processor';
}
}

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Logging;
use App\Framework\Logging\LogContextManager;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\RequestContext;
use App\Framework\Logging\ValueObjects\UserContext;
use App\Framework\Random\SecureRandomGenerator;
use App\Framework\Tracing\TraceContext;
describe('LogContextManager', function () {
beforeEach(function () {
// Create fresh instance for each test
$this->contextManager = new LogContextManager();
TraceContext::clear();
});
afterEach(function () {
// Clean up after each test
TraceContext::clear();
});
it('starts with empty context', function () {
$context = $this->contextManager->getCurrentContext();
expect($context->structured)->toBe([]);
expect($context->tags)->toBe([]);
expect($context->trace)->toBeNull();
expect($context->user)->toBeNull();
expect($context->request)->toBeNull();
});
it('can set and get global context', function () {
$globalContext = LogContext::withData(['app' => 'test'])
->addTags('global');
$this->contextManager->setGlobalContext($globalContext);
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['app' => 'test']);
expect($current->tags)->toBe(['global']);
});
it('can set and get request context', function () {
$requestContext = LogContext::withData(['request' => 'test'])
->addTags('request');
$this->contextManager->setRequestContext($requestContext);
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['request' => 'test']);
expect($current->tags)->toBe(['request']);
});
it('merges global and request contexts', function () {
$globalContext = LogContext::withData(['app' => 'test'])
->addTags('global');
$requestContext = LogContext::withData(['request' => 'test'])
->addTags('request');
$this->contextManager->setGlobalContext($globalContext);
$this->contextManager->setRequestContext($requestContext);
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['app' => 'test', 'request' => 'test']);
expect($current->tags)->toBe(['global', 'request']);
});
it('can add data to global context', function () {
$this->contextManager->addGlobalData('key1', 'value1');
$this->contextManager->addGlobalData('key2', 'value2');
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['key1' => 'value1', 'key2' => 'value2']);
});
it('can add tags to global context', function () {
$this->contextManager->addGlobalTags('tag1', 'tag2');
$this->contextManager->addGlobalTags('tag3');
$current = $this->contextManager->getCurrentContext();
expect($current->tags)->toBe(['tag1', 'tag2', 'tag3']);
});
it('can add data to request context', function () {
$this->contextManager->addRequestData('req1', 'value1');
$this->contextManager->addRequestData('req2', 'value2');
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['req1' => 'value1', 'req2' => 'value2']);
});
it('can add tags to request context', function () {
$this->contextManager->addRequestTags('reqtag1', 'reqtag2');
$this->contextManager->addRequestTags('reqtag3');
$current = $this->contextManager->getCurrentContext();
expect($current->tags)->toBe(['reqtag1', 'reqtag2', 'reqtag3']);
});
it('includes current trace context automatically', function () {
$trace = TraceContext::start(new SecureRandomGenerator());
$current = $this->contextManager->getCurrentContext();
expect($current->trace)->toBe($trace);
});
it('can add user context', function () {
$userContext = UserContext::authenticated('123', 'john');
$this->contextManager->withUser($userContext);
$current = $this->contextManager->getCurrentContext();
expect($current->user)->toBe($userContext);
});
it('can add request context through manager', function () {
$requestContext = RequestContext::empty();
$this->contextManager->withRequest($requestContext);
$current = $this->contextManager->getCurrentContext();
expect($current->request)->toBe($requestContext);
});
it('can add trace context through manager', function () {
$traceContext = TraceContext::start(new SecureRandomGenerator());
$this->contextManager->withTrace($traceContext);
$current = $this->contextManager->getCurrentContext();
expect($current->trace)->toBe($traceContext);
});
it('can clear request context only', function () {
$this->contextManager->setGlobalContext(LogContext::withData(['global' => 'data']));
$this->contextManager->setRequestContext(LogContext::withData(['request' => 'data']));
$this->contextManager->clearRequestContext();
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['global' => 'data']);
});
it('can clear global context only', function () {
$this->contextManager->setGlobalContext(LogContext::withData(['global' => 'data']));
$this->contextManager->setRequestContext(LogContext::withData(['request' => 'data']));
$this->contextManager->clearGlobalContext();
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['request' => 'data']);
});
it('can clear all contexts', function () {
$this->contextManager->setGlobalContext(LogContext::withData(['global' => 'data']));
$this->contextManager->setRequestContext(LogContext::withData(['request' => 'data']));
$this->contextManager->clearContext();
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['global' => 'data']); // Global remains
});
it('can execute callback with temporary context', function () {
$originalData = ['original' => 'data'];
$this->contextManager->setRequestContext(LogContext::withData($originalData));
$temporaryContext = LogContext::withData(['temp' => 'data']);
$result = $this->contextManager->withTemporaryContext($temporaryContext, function () {
$current = $this->contextManager->getCurrentContext();
return $current->structured;
});
expect($result)->toBe(['original' => 'data', 'temp' => 'data']);
// Original context should be restored
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe($originalData);
});
it('can execute callback with temporary tags', function () {
$this->contextManager->setRequestContext(LogContext::withTags('original'));
$result = $this->contextManager->withTemporaryTags(['temp1', 'temp2'], function () {
$current = $this->contextManager->getCurrentContext();
return $current->tags;
});
expect($result)->toBe(['original', 'temp1', 'temp2']);
// Original context should be restored
$current = $this->contextManager->getCurrentContext();
expect($current->tags)->toBe(['original']);
});
it('can execute callback with temporary data', function () {
$this->contextManager->setRequestContext(LogContext::withData(['original' => 'value']));
$result = $this->contextManager->withTemporaryData(['temp' => 'value'], function () {
$current = $this->contextManager->getCurrentContext();
return $current->structured;
});
expect($result)->toBe(['original' => 'value', 'temp' => 'value']);
// Original context should be restored
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['original' => 'value']);
});
it('restores context even when callback throws exception', function () {
$originalContext = LogContext::withData(['original' => 'data']);
$this->contextManager->setRequestContext($originalContext);
$temporaryContext = LogContext::withData(['temp' => 'data']);
try {
$this->contextManager->withTemporaryContext($temporaryContext, function () {
throw new \RuntimeException('Test exception');
});
} catch (\RuntimeException $e) {
// Expected
}
// Original context should be restored
$current = $this->contextManager->getCurrentContext();
expect($current->structured)->toBe(['original' => 'data']);
});
it('can initialize request context from globals', function () {
// Mock global variables
$_SERVER['REQUEST_METHOD'] = 'GET';
$_SERVER['REQUEST_URI'] = '/test';
$_SERVER['HTTP_HOST'] = 'example.com';
$_SERVER['SERVER_NAME'] = 'example.com';
$_SERVER['SERVER_PORT'] = '80';
// Test direct RequestContext creation first
$requestContext = \App\Framework\Logging\ValueObjects\RequestContext::fromGlobals();
expect($requestContext)->not->toBeNull();
expect($requestContext->getMethod())->toBe('GET');
$context = $this->contextManager->initializeRequestContext();
expect($context->request)->not->toBeNull();
expect($context->request->getMethod())->toBe('GET');
expect($context->request->getUri())->toBe('/test');
expect($context->request->getHost())->toBe('example.com');
// Should be set as current request context
$current = $this->contextManager->getCurrentContext();
expect($current->request)->toBe($context->request);
// Cleanup
unset($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $_SERVER['HTTP_HOST'], $_SERVER['SERVER_NAME'], $_SERVER['SERVER_PORT']);
});
it('provides debug information', function () {
$this->contextManager->setGlobalContext(LogContext::withData(['global' => 'data']));
$this->contextManager->setRequestContext(LogContext::withData(['request' => 'data']));
$debug = $this->contextManager->getDebugInfo();
expect($debug)->toHaveKeys([
'has_global_context',
'has_request_context',
'global_context',
'request_context',
'current_trace',
'combined_context',
]);
expect($debug['has_global_context'])->toBeTrue();
expect($debug['has_request_context'])->toBeTrue();
});
it('can check for trace context', function () {
expect($this->contextManager->hasTraceContext())->toBeFalse();
TraceContext::start(new SecureRandomGenerator());
expect($this->contextManager->hasTraceContext())->toBeTrue();
});
it('can check for user context', function () {
expect($this->contextManager->hasUserContext())->toBeFalse();
$this->contextManager->withUser(UserContext::authenticated('123'));
expect($this->contextManager->hasUserContext())->toBeTrue();
});
it('can check for request context', function () {
expect($this->contextManager->hasRequestContext())->toBeFalse();
$this->contextManager->withRequest(RequestContext::empty());
expect($this->contextManager->hasRequestContext())->toBeTrue();
});
});

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Logging\ValueObjects;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\ValueObjects\RequestContext;
use App\Framework\Logging\ValueObjects\UserContext;
use App\Framework\Random\SecureRandomGenerator;
use App\Framework\Tracing\TraceContext;
describe('LogContext', function () {
it('can be created empty', 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([]);
});
it('can be created with structured data', function () {
$data = ['user_id' => 123, 'action' => 'login'];
$context = LogContext::withData($data);
expect($context->structured)->toBe($data);
expect($context->hasStructuredData())->toBeTrue();
});
it('can be created with tags', function () {
$context = LogContext::withTags('auth', 'user-action');
expect($context->tags)->toBe(['auth', 'user-action']);
expect($context->hasTags())->toBeTrue();
expect($context->hasTag('auth'))->toBeTrue();
expect($context->hasTag('missing'))->toBeFalse();
});
it('can add structured data immutably', function () {
$original = LogContext::withData(['key1' => 'value1']);
$modified = $original->addData('key2', 'value2');
expect($original->structured)->toBe(['key1' => 'value1']);
expect($modified->structured)->toBe(['key1' => 'value1', 'key2' => 'value2']);
});
it('can merge structured data immutably', function () {
$original = LogContext::withData(['key1' => 'value1']);
$modified = $original->mergeData(['key2' => 'value2', 'key3' => 'value3']);
expect($original->structured)->toBe(['key1' => 'value1']);
expect($modified->structured)->toBe([
'key1' => 'value1',
'key2' => 'value2',
'key3' => 'value3',
]);
});
it('can add tags immutably', function () {
$original = LogContext::withTags('tag1');
$modified = $original->addTags('tag2', 'tag3');
expect($original->tags)->toBe(['tag1']);
expect($modified->tags)->toBe(['tag1', 'tag2', 'tag3']);
});
it('removes duplicate tags when adding', function () {
$original = LogContext::withTags('tag1', 'tag2');
$modified = $original->addTags('tag2', 'tag3');
expect($modified->tags)->toBe(['tag1', 'tag2', 'tag3']);
});
it('can add user context immutably', function () {
$userContext = UserContext::authenticated('123', 'john');
$original = LogContext::empty();
$modified = $original->withUser($userContext);
expect($original->user)->toBeNull();
expect($modified->user)->toBe($userContext);
});
it('can add request context immutably', function () {
$requestContext = RequestContext::empty();
$original = LogContext::empty();
$modified = $original->withRequest($requestContext);
expect($original->request)->toBeNull();
expect($modified->request)->toBe($requestContext);
});
it('can add trace context immutably', function () {
$traceContext = TraceContext::start(new SecureRandomGenerator());
$original = LogContext::empty();
$modified = $original->withTrace($traceContext);
expect($original->trace)->toBeNull();
expect($modified->trace)->toBe($traceContext);
});
it('can add metadata immutably', function () {
$original = LogContext::empty();
$modified = $original->addMetadata('source', 'test');
expect($original->metadata)->toBe([]);
expect($modified->metadata)->toBe(['source' => 'test']);
});
it('can merge two contexts', function () {
$context1 = LogContext::withData(['key1' => 'value1'])
->addTags('tag1')
->addMetadata('meta1', 'value1');
$context2 = LogContext::withData(['key2' => 'value2'])
->addTags('tag2')
->addMetadata('meta2', 'value2');
$merged = $context1->merge($context2);
expect($merged->structured)->toBe(['key1' => 'value1', 'key2' => 'value2']);
expect($merged->tags)->toBe(['tag1', 'tag2']);
expect($merged->metadata)->toBe(['meta1' => 'value1', 'meta2' => 'value2']);
});
it('prioritizes second context in merge for trace, user, request', function () {
$user1 = UserContext::authenticated('123');
$user2 = UserContext::authenticated('456');
$request1 = RequestContext::empty();
$request2 = RequestContext::empty();
$trace1 = TraceContext::start(new SecureRandomGenerator());
$trace2 = TraceContext::start(new SecureRandomGenerator());
$context1 = LogContext::empty()
->withUser($user1)
->withRequest($request1)
->withTrace($trace1);
$context2 = LogContext::empty()
->withUser($user2)
->withRequest($request2)
->withTrace($trace2);
$merged = $context1->merge($context2);
expect($merged->user)->toBe($user2);
expect($merged->request)->toBe($request2);
expect($merged->trace)->toBe($trace2);
});
it('can convert to array', function () {
$userContext = UserContext::authenticated('123', 'john');
$requestContext = RequestContext::empty();
$traceContext = TraceContext::start(new SecureRandomGenerator());
$context = LogContext::withData(['key' => 'value'])
->addTags('tag1', 'tag2')
->withUser($userContext)
->withRequest($requestContext)
->withTrace($traceContext)
->addMetadata('source', 'test');
$array = $context->toArray();
expect($array)->toHaveKeys([
'structured',
'tags',
'trace',
'user',
'request',
'metadata',
]);
expect($array['structured'])->toBe(['key' => 'value']);
expect($array['tags'])->toBe(['tag1', 'tag2']);
expect($array['metadata'])->toBe(['source' => 'test']);
});
it('only includes non-empty sections in toArray', function () {
$context = LogContext::withData(['key' => 'value']);
$array = $context->toArray();
expect($array)->toHaveKey('structured');
expect($array)->not->toHaveKey('tags');
expect($array)->not->toHaveKey('trace');
expect($array)->not->toHaveKey('user');
expect($array)->not->toHaveKey('request');
expect($array)->not->toHaveKey('metadata');
});
it('can create context with current trace', function () {
// Clear any existing trace
TraceContext::clear();
// Start a new trace
$trace = TraceContext::start(new SecureRandomGenerator());
$context = LogContext::withCurrentTrace();
expect($context->trace)->toBe($trace);
// Cleanup
TraceContext::clear();
});
it('creates context with null trace when no current trace', function () {
TraceContext::clear();
$context = LogContext::withCurrentTrace();
expect($context->trace)->toBeNull();
});
});

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Logging\ValueObjects;
use App\Framework\Logging\ValueObjects\UserContext;
describe('UserContext', function () {
it('can create authenticated user context', function () {
$context = UserContext::authenticated(
userId: '123',
username: 'john_doe',
email: 'john@example.com',
sessionId: 'sess_123',
roles: ['user', 'admin'],
permissions: ['read', 'write'],
authMethod: 'session'
);
expect($context->userId)->toBe('123');
expect($context->username)->toBe('john_doe');
expect($context->email)->toBe('john@example.com');
expect($context->sessionId)->toBe('sess_123');
expect($context->roles)->toBe(['user', 'admin']);
expect($context->permissions)->toBe(['read', 'write']);
expect($context->authMethod)->toBe('session');
expect($context->isAuthenticated)->toBeTrue();
});
it('can create anonymous user context', function () {
$context = UserContext::anonymous('sess_456');
expect($context->userId)->toBeNull();
expect($context->username)->toBeNull();
expect($context->email)->toBeNull();
expect($context->sessionId)->toBe('sess_456');
expect($context->roles)->toBe([]);
expect($context->permissions)->toBe([]);
expect($context->authMethod)->toBeNull();
expect($context->isAuthenticated)->toBeFalse();
});
it('can create from session data with user_id key', function () {
$sessionData = [
'user_id' => '123',
'username' => 'john',
'email' => 'john@example.com',
'roles' => ['user'],
'permissions' => ['read'],
'session_id' => 'sess_123',
'auth_method' => 'oauth',
'extra_data' => 'some_value',
];
$context = UserContext::fromSession($sessionData);
expect($context->userId)->toBe('123');
expect($context->username)->toBe('john');
expect($context->email)->toBe('john@example.com');
expect($context->roles)->toBe(['user']);
expect($context->permissions)->toBe(['read']);
expect($context->sessionId)->toBe('sess_123');
expect($context->authMethod)->toBe('oauth');
expect($context->isAuthenticated)->toBeTrue();
expect($context->metadata)->toBe(['extra_data' => 'some_value']);
});
it('can create from session data with alternative user id keys', function () {
$sessionData = [
'id' => '456',
'name' => 'jane',
];
$context = UserContext::fromSession($sessionData);
expect($context->userId)->toBe('456');
expect($context->username)->toBe('jane');
expect($context->isAuthenticated)->toBeTrue();
});
it('creates anonymous context when no user id in session', function () {
$sessionData = [
'some_other_data' => 'value',
];
$context = UserContext::fromSession($sessionData);
expect($context->userId)->toBeNull();
expect($context->isAuthenticated)->toBeFalse();
expect($context->metadata)->toBe(['some_other_data' => 'value']);
});
it('can add role immutably', function () {
$original = UserContext::authenticated('123', roles: ['user']);
$modified = $original->withRole('admin');
expect($original->roles)->toBe(['user']);
expect($modified->roles)->toBe(['user', 'admin']);
});
it('removes duplicate roles when adding', function () {
$original = UserContext::authenticated('123', roles: ['user', 'admin']);
$modified = $original->withRole('user');
expect($modified->roles)->toBe(['user', 'admin']);
});
it('can add permission immutably', function () {
$original = UserContext::authenticated('123', permissions: ['read']);
$modified = $original->withPermission('write');
expect($original->permissions)->toBe(['read']);
expect($modified->permissions)->toBe(['read', 'write']);
});
it('removes duplicate permissions when adding', function () {
$original = UserContext::authenticated('123', permissions: ['read', 'write']);
$modified = $original->withPermission('read');
expect($modified->permissions)->toBe(['read', 'write']);
});
it('can add metadata immutably', function () {
$original = UserContext::authenticated('123');
$modified = $original->withMetadata('source', 'api');
expect($original->metadata)->toBe([]);
expect($modified->metadata)->toBe(['source' => 'api']);
});
it('can check if user has role', function () {
$context = UserContext::authenticated('123', roles: ['user', 'admin']);
expect($context->hasRole('user'))->toBeTrue();
expect($context->hasRole('admin'))->toBeTrue();
expect($context->hasRole('superuser'))->toBeFalse();
});
it('can check if user has permission', function () {
$context = UserContext::authenticated('123', permissions: ['read', 'write']);
expect($context->hasPermission('read'))->toBeTrue();
expect($context->hasPermission('write'))->toBeTrue();
expect($context->hasPermission('delete'))->toBeFalse();
});
it('generates anonymized id from user id', function () {
$context = UserContext::authenticated('123456789');
$anonymizedId = $context->getAnonymizedId();
expect($anonymizedId)->not->toBeNull();
expect($anonymizedId)->toHaveLength(8);
expect($anonymizedId)->not->toBe('123456789');
// Same user ID should generate same anonymized ID
$context2 = UserContext::authenticated('123456789');
expect($context2->getAnonymizedId())->toBe($anonymizedId);
});
it('returns null anonymized id for anonymous users', function () {
$context = UserContext::anonymous();
expect($context->getAnonymizedId())->toBeNull();
});
it('masks email addresses', function () {
$context = UserContext::authenticated('123', email: 'john.doe@example.com');
$maskedEmail = $context->getMaskedEmail();
expect($maskedEmail)->toBe('j******e@example.com');
});
it('masks short email addresses', function () {
$context = UserContext::authenticated('123', email: 'a@b.com');
$maskedEmail = $context->getMaskedEmail();
expect($maskedEmail)->toBe('*@b.com');
});
it('handles invalid email format', function () {
$context = UserContext::authenticated('123', email: 'invalid-email');
$maskedEmail = $context->getMaskedEmail();
expect($maskedEmail)->toBe('***@***');
});
it('returns null masked email when no email', function () {
$context = UserContext::authenticated('123');
expect($context->getMaskedEmail())->toBeNull();
});
it('converts to array with all data', function () {
$context = UserContext::authenticated(
userId: '123',
username: 'john',
email: 'john@example.com',
sessionId: 'sess_123',
roles: ['user'],
permissions: ['read'],
authMethod: 'session'
)->withMetadata('source', 'test');
$array = $context->toArray();
expect($array)->toBe([
'user_id' => '123',
'is_authenticated' => true,
'username' => 'john',
'email_masked' => 'j**n@example.com',
'session_id' => 'sess_123',
'roles' => ['user'],
'permissions' => ['read'],
'auth_method' => 'session',
'metadata' => ['source' => 'test'],
]);
});
it('converts to array with minimal data', function () {
$context = UserContext::authenticated('123');
$array = $context->toArray();
expect($array)->toBe([
'user_id' => '123',
'is_authenticated' => true,
'auth_method' => 'session',
]);
});
it('converts to privacy safe array', function () {
$context = UserContext::authenticated(
userId: '123',
username: 'john',
email: 'john@example.com',
sessionId: 'sess_123',
roles: ['user', 'admin'],
permissions: ['read', 'write', 'delete'],
authMethod: 'oauth'
);
$array = $context->toPrivacySafeArray();
expect($array)->toBe([
'user_id_anonymized' => $context->getAnonymizedId(),
'is_authenticated' => true,
'roles_count' => 2,
'permissions_count' => 3,
'auth_method' => 'oauth',
'has_session' => true,
]);
});
it('extracts user id from object in session data', function () {
$userObject = new class () {
public $id = '789';
};
$sessionData = ['user' => $userObject];
$context = UserContext::fromSession($sessionData);
expect($context->userId)->toBe('789');
expect($context->isAuthenticated)->toBeTrue();
});
it('extracts user id from array in session data', function () {
$sessionData = ['logged_in_user' => ['id' => '999']];
$context = UserContext::fromSession($sessionData);
expect($context->userId)->toBe('999');
expect($context->isAuthenticated)->toBeTrue();
});
});