fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled

- Remove middleware reference from Gitea Traefik labels (caused routing issues)
- Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s)
- Add explicit service reference in Traefik labels
- Fix intermittent 504 timeouts by improving PostgreSQL connection handling

Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
2025-11-09 14:46:15 +01:00
parent 85c369e846
commit 36ef2a1e2c
1366 changed files with 104925 additions and 28719 deletions

View File

@@ -1,340 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\ErrorHandling;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheIdentifier;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheResult;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\DateTime\Clock;
use App\Framework\ErrorAggregation\ErrorAggregator;
use App\Framework\ErrorAggregation\ErrorEvent;
use App\Framework\ErrorAggregation\Storage\InMemoryErrorStorage;
use App\Framework\Exception\Core\CacheErrorCode;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\Core\ErrorSeverity;
use App\Framework\Exception\Core\SystemErrorCode;
use App\Framework\Exception\ErrorHandlerContext;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\RequestContext;
use App\Framework\Exception\SystemContext;
use App\Framework\Queue\InMemoryQueue;
// Helper function für Mock-Objekte
function createTestCache(): Cache
{
return new class implements Cache {
private array $data = [];
public function get(CacheIdentifier ...$identifiers): CacheResult
{
$items = [];
foreach ($identifiers as $identifier) {
if (isset($this->data[$identifier->toString()])) {
$items[] = $this->data[$identifier->toString()];
} else {
$items[] = CacheItem::miss($identifier instanceof CacheKey ? $identifier : CacheKey::fromString($identifier->toString()));
}
}
return CacheResult::fromItems(...$items);
}
public function set(CacheItem ...$items): bool
{
foreach ($items as $item) {
$this->data[$item->key->toString()] = $item;
}
return true;
}
public function has(CacheIdentifier ...$identifiers): array
{
$result = [];
foreach ($identifiers as $identifier) {
$key = (string) $identifier;
$result[$key] = isset($this->data[$key]);
}
return $result;
}
public function forget(CacheIdentifier ...$identifiers): bool
{
foreach ($identifiers as $identifier) {
$key = (string) $identifier;
unset($this->data[$key]);
}
return true;
}
public function clear(): bool
{
$this->data = [];
return true;
}
public function remember(CacheKey $key, callable $callback, ?Duration $ttl = null): CacheItem
{
$keyStr = $key->toString();
if (isset($this->data[$keyStr])) {
return $this->data[$keyStr];
}
$value = $callback();
$item = CacheItem::forSetting($key, $value, $ttl);
$this->data[$keyStr] = $item;
return $item;
}
};
}
function createTestClock(): Clock
{
return new class implements Clock {
public function now(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
public function fromTimestamp(Timestamp $timestamp): \DateTimeImmutable
{
return $timestamp->toDateTime();
}
public function fromString(string $dateTime, ?string $format = null): \DateTimeImmutable
{
return new \DateTimeImmutable($dateTime);
}
public function today(): \DateTimeImmutable
{
return new \DateTimeImmutable('today');
}
public function yesterday(): \DateTimeImmutable
{
return new \DateTimeImmutable('yesterday');
}
public function tomorrow(): \DateTimeImmutable
{
return new \DateTimeImmutable('tomorrow');
}
public function time(): Timestamp
{
return Timestamp::now();
}
};
}
describe('ErrorHandler ErrorAggregator Integration', function () {
it('processes ErrorHandlerContext through ErrorAggregator', function () {
// Setup ErrorAggregator
$storage = new InMemoryErrorStorage();
$cache = createTestCache();
$clock = createTestClock();
$alertQueue = new InMemoryQueue();
$errorAggregator = new ErrorAggregator(
storage: $storage,
cache: $cache,
clock: $clock,
alertQueue: $alertQueue
);
// Create ErrorHandlerContext (simulates what ErrorHandler does)
$exception = FrameworkException::create(
DatabaseErrorCode::QUERY_FAILED,
'Test database query failed'
);
$exceptionContext = ExceptionContext::empty()
->withOperation('test_operation', 'TestComponent')
->withData([
'test_key' => 'test_value',
'original_exception' => $exception, // Store exception for message extraction
'exception_message' => $exception->getMessage()
]);
$requestContext = RequestContext::fromGlobals();
$systemContext = SystemContext::current();
$errorHandlerContext = ErrorHandlerContext::create(
$exceptionContext,
$requestContext,
$systemContext,
['http_status' => 500]
);
// Process through ErrorAggregator (simulates ErrorHandler calling it)
$errorAggregator->processError($errorHandlerContext);
// Verify error was stored
$recentEvents = $errorAggregator->getRecentEvents(10);
expect($recentEvents)->toHaveCount(1);
$event = $recentEvents[0];
expect($event->errorMessage)->toBe('Test database query failed');
expect($event->severity)->toBe(ErrorSeverity::ERROR);
expect($event->component)->toBe('TestComponent');
expect($event->operation)->toBe('test_operation');
// Verify pattern was created
$activePatterns = $errorAggregator->getActivePatterns(10);
expect($activePatterns)->toHaveCount(1);
$pattern = $activePatterns[0];
expect($pattern->occurrenceCount)->toBe(1);
expect($pattern->severity)->toBe(ErrorSeverity::ERROR);
expect($pattern->component)->toBe('TestComponent');
});
it('creates error patterns from multiple identical errors', function () {
$storage = new InMemoryErrorStorage();
$cache = createTestCache();
$clock = createTestClock();
$alertQueue = new InMemoryQueue();
$errorAggregator = new ErrorAggregator(
storage: $storage,
cache: $cache,
clock: $clock,
alertQueue: $alertQueue
);
// Process same error 3 times
for ($i = 0; $i < 3; $i++) {
$exception = FrameworkException::create(
DatabaseErrorCode::QUERY_FAILED,
'Repeated database error'
);
$exceptionContext = ExceptionContext::empty()
->withOperation('query_execution', 'DatabaseManager')
->withData([
'query' => 'SELECT * FROM users',
'original_exception' => $exception,
'exception_message' => $exception->getMessage()
]);
$requestContext = RequestContext::fromGlobals();
$systemContext = SystemContext::current();
$errorHandlerContext = ErrorHandlerContext::create(
$exceptionContext,
$requestContext,
$systemContext,
['http_status' => 500]
);
$errorAggregator->processError($errorHandlerContext);
}
// Verify all 3 events were stored
$recentEvents = $errorAggregator->getRecentEvents(10);
expect($recentEvents)->toHaveCount(3);
// Verify single pattern with 3 occurrences (same fingerprint)
$activePatterns = $errorAggregator->getActivePatterns(10);
expect($activePatterns)->toHaveCount(1);
$pattern = $activePatterns[0];
expect($pattern->occurrenceCount)->toBe(3);
expect($pattern->component)->toBe('DatabaseManager');
expect($pattern->operation)->toBe('query_execution');
});
it('handles ErrorAggregator being null (optional dependency)', function () {
$errorAggregator = null;
// Simulate the nullable call pattern used in ErrorHandler
$exception = FrameworkException::create(
DatabaseErrorCode::QUERY_FAILED,
'Test error'
);
$exceptionContext = ExceptionContext::empty();
$requestContext = RequestContext::fromGlobals();
$systemContext = SystemContext::current();
$errorHandlerContext = ErrorHandlerContext::create(
$exceptionContext,
$requestContext,
$systemContext,
[]
);
// This should not throw an error
$errorAggregator?->processError($errorHandlerContext);
// If we get here, the null-safe operator worked correctly
expect(true)->toBeTrue();
});
it('processes errors with different severities correctly', function () {
$storage = new InMemoryErrorStorage();
$cache = createTestCache();
$clock = createTestClock();
$alertQueue = new InMemoryQueue();
$errorAggregator = new ErrorAggregator(
storage: $storage,
cache: $cache,
clock: $clock,
alertQueue: $alertQueue
);
// Process errors with different severities
$errorCodes = [
SystemErrorCode::RESOURCE_EXHAUSTED, // CRITICAL
DatabaseErrorCode::QUERY_FAILED, // ERROR
CacheErrorCode::READ_FAILED, // WARNING
];
foreach ($errorCodes as $errorCode) {
$exception = FrameworkException::create($errorCode, 'Test message');
$exceptionContext = ExceptionContext::empty()
->withOperation('test_op', 'TestComponent')
->withData([
'original_exception' => $exception,
'exception_message' => $exception->getMessage()
]);
$requestContext = RequestContext::fromGlobals();
$systemContext = SystemContext::current();
$errorHandlerContext = ErrorHandlerContext::create(
$exceptionContext,
$requestContext,
$systemContext,
[]
);
$errorAggregator->processError($errorHandlerContext);
}
// Verify all events were stored
$recentEvents = $errorAggregator->getRecentEvents(10);
expect($recentEvents)->toHaveCount(3);
// Verify patterns reflect correct severities
$activePatterns = $errorAggregator->getActivePatterns(10);
expect($activePatterns)->toHaveCount(3);
$severities = array_map(fn($p) => $p->severity, $activePatterns);
expect($severities)->toContain(ErrorSeverity::CRITICAL);
expect($severities)->toContain(ErrorSeverity::ERROR);
expect($severities)->toContain(ErrorSeverity::WARNING);
});
});

View File

@@ -1,298 +0,0 @@
<?php
declare(strict_types=1);
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheIdentifier;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheResult;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\SystemClock;
use App\Framework\ErrorAggregation\ErrorAggregator;
use App\Framework\ErrorAggregation\ErrorAggregatorInterface;
use App\Framework\ErrorAggregation\Storage\InMemoryErrorStorage;
use App\Framework\ErrorHandling\ErrorHandler;
use App\Framework\ErrorHandling\ErrorHandlerManager;
use App\Framework\ErrorHandling\ErrorHandlerRegistry;
use App\Framework\ErrorReporting\ErrorReporter;
use App\Framework\ErrorReporting\ErrorReporterInterface;
use App\Framework\ErrorReporting\Storage\InMemoryErrorReportStorage;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\Core\ErrorSeverity;
use App\Framework\Exception\Core\SystemErrorCode;
use App\Framework\Exception\DatabaseException;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\RequestIdGenerator;
use App\Framework\Http\ResponseEmitter;
use App\Framework\DI\DefaultContainer;
use App\Framework\Queue\InMemoryQueue;
use App\Framework\Logging\Logger;
use App\Framework\Logging\LogLevel;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\InMemoryLogger;
describe('ErrorHandler Full Pipeline Integration', function () {
beforeEach(function () {
// Create all dependencies
$this->container = new DefaultContainer();
// Create and bind InMemoryLogger for testing
$this->logger = new InMemoryLogger();
$this->container->bind(Logger::class, fn() => $this->logger);
$this->emitter = new ResponseEmitter();
$this->requestIdGenerator = new RequestIdGenerator();
// Error Aggregation setup
$this->errorStorage = new InMemoryErrorStorage();
$this->cache = new class implements Cache {
private array $data = [];
public function get(CacheIdentifier ...$identifiers): CacheResult
{
$items = [];
foreach ($identifiers as $identifier) {
$keyStr = $identifier->toString();
if (isset($this->data[$keyStr])) {
$items[] = $this->data[$keyStr];
} else {
$items[] = CacheItem::miss($identifier instanceof CacheKey ? $identifier : CacheKey::fromString($keyStr));
}
}
return CacheResult::fromItems(...$items);
}
public function set(CacheItem ...$items): bool
{
foreach ($items as $item) {
$this->data[$item->key->toString()] = $item;
}
return true;
}
public function has(CacheIdentifier ...$identifiers): array
{
$result = [];
foreach ($identifiers as $identifier) {
$result[] = isset($this->data[$identifier->toString()]);
}
return $result;
}
public function forget(CacheIdentifier ...$identifiers): bool
{
foreach ($identifiers as $identifier) {
unset($this->data[$identifier->toString()]);
}
return true;
}
public function clear(): bool
{
$this->data = [];
return true;
}
public function remember(CacheKey $key, callable $callback, ?Duration $ttl = null): CacheItem
{
$keyStr = $key->toString();
if (isset($this->data[$keyStr])) {
return $this->data[$keyStr];
}
$value = $callback();
$item = $ttl ? CacheItem::forSet($key, $value, $ttl) : CacheItem::miss($key);
$this->data[$keyStr] = $item;
return $item;
}
};
$this->clock = new SystemClock();
$this->alertQueue = new InMemoryQueue();
$this->errorAggregator = new ErrorAggregator(
storage: $this->errorStorage,
cache: $this->cache,
clock: $this->clock,
alertQueue: $this->alertQueue,
logger: $this->logger,
batchSize: 100,
maxRetentionDays: 90
);
// Error Reporting setup
$this->errorReportStorage = new InMemoryErrorReportStorage();
$this->reportQueue = new InMemoryQueue();
$this->errorReporter = new ErrorReporter(
storage: $this->errorReportStorage,
clock: $this->clock,
logger: $this->logger,
queue: $this->reportQueue,
asyncProcessing: false, // Synchronous for testing
processors: [],
filters: []
);
// Create ErrorHandlerManager
$registry = new ErrorHandlerRegistry();
$this->handlerManager = new ErrorHandlerManager($registry);
// Create ErrorHandler with full pipeline
$this->errorHandler = new ErrorHandler(
emitter: $this->emitter,
container: $this->container,
requestIdGenerator: $this->requestIdGenerator,
errorAggregator: $this->errorAggregator,
errorReporter: $this->errorReporter,
handlerManager: $this->handlerManager,
logger: $this->logger,
isDebugMode: true,
securityHandler: null
);
});
it('processes errors through complete pipeline: ErrorHandler → ErrorAggregator → ErrorReporter', function () {
// Create a test exception
$exception = FrameworkException::create(
DatabaseErrorCode::QUERY_FAILED,
'Test database error'
);
// Create HTTP response (triggers processing through all systems)
$response = $this->errorHandler->createHttpResponse($exception);
// Verify ErrorAggregator processed the error
$events = $this->errorStorage->getRecentEvents(10);
expect($events)->toHaveCount(1);
expect($events[0]->errorMessage)->toBe('Test database error');
expect($events[0]->severity)->toBe(ErrorSeverity::ERROR);
// Verify ErrorReporter created a report
$reports = $this->errorReportStorage->findRecent(10);
expect($reports)->toHaveCount(1);
expect($reports[0]->exception)->toBe('App\Framework\Exception\FrameworkException');
expect($reports[0]->message)->toContain('Test database error');
expect($reports[0]->level)->toBe('error');
});
it('creates error patterns and error reports simultaneously', function () {
// Create multiple identical errors
$exception1 = FrameworkException::create(
SystemErrorCode::RESOURCE_EXHAUSTED,
'Memory limit exceeded'
);
$exception2 = FrameworkException::create(
SystemErrorCode::RESOURCE_EXHAUSTED,
'Memory limit exceeded'
);
$exception3 = FrameworkException::create(
SystemErrorCode::RESOURCE_EXHAUSTED,
'Memory limit exceeded'
);
// Process all exceptions
$this->errorHandler->createHttpResponse($exception1);
$this->errorHandler->createHttpResponse($exception2);
$this->errorHandler->createHttpResponse($exception3);
// Verify ErrorAggregator created patterns
$patterns = $this->errorStorage->getActivePatterns(10);
expect($patterns)->toHaveCount(1);
expect($patterns[0]->occurrenceCount)->toBe(3);
expect($patterns[0]->severity)->toBe(ErrorSeverity::CRITICAL);
// Verify ErrorReporter created individual reports
$reports = $this->errorReportStorage->findRecent(10);
expect($reports)->toHaveCount(3);
foreach ($reports as $report) {
expect($report->message)->toContain('Memory limit exceeded');
}
});
it('handles different error types through pipeline', function () {
// Database error using migrated DatabaseException
$dbException = DatabaseException::queryFailed(
sql: 'SELECT * FROM users WHERE id = ?',
error: 'Table users does not exist'
);
// System error
$sysException = FrameworkException::create(
SystemErrorCode::RESOURCE_EXHAUSTED,
'CPU limit exceeded'
);
// Process both
$this->errorHandler->createHttpResponse($dbException);
$this->errorHandler->createHttpResponse($sysException);
// Verify ErrorAggregator
$events = $this->errorStorage->getRecentEvents(10);
expect($events)->toHaveCount(2);
$severities = array_map(fn($e) => $e->severity, $events);
expect($severities)->toContain(ErrorSeverity::ERROR); // Database
expect($severities)->toContain(ErrorSeverity::CRITICAL); // System
// Verify ErrorReporter
$reports = $this->errorReportStorage->findRecent(10);
expect($reports)->toHaveCount(2);
});
it('propagates error context through entire pipeline', function () {
$exception = FrameworkException::create(
DatabaseErrorCode::QUERY_FAILED,
'Complex query failed'
)->withData([
'query' => 'SELECT * FROM large_table WHERE id IN (...)',
'execution_time' => 5.2
]);
// Process exception
$this->errorHandler->createHttpResponse($exception);
// Verify ErrorAggregator has context
$events = $this->errorStorage->getRecentEvents(10);
expect($events[0]->context)->toBeArray();
expect($events[0]->context)->toHaveKey('query');
expect($events[0]->context)->toHaveKey('execution_time');
// Verify ErrorReporter has full context
$reports = $this->errorReportStorage->findRecent(10);
expect($reports[0]->context)->toBeArray();
expect($reports[0]->context)->toHaveKey('exception');
});
it('uses interfaces for dependency injection', function () {
// Verify ErrorHandler accepts interfaces
expect($this->errorHandler)
->toBeInstanceOf(ErrorHandler::class);
// Verify dependencies are interface-based (via reflection)
$reflection = new \ReflectionClass(ErrorHandler::class);
$constructor = $reflection->getConstructor();
$aggregatorParam = null;
$reporterParam = null;
foreach ($constructor->getParameters() as $param) {
if ($param->getName() === 'errorAggregator') {
$aggregatorParam = $param;
}
if ($param->getName() === 'errorReporter') {
$reporterParam = $param;
}
}
// Verify parameters use interfaces
expect($aggregatorParam->getType()->getName())
->toBe(ErrorAggregatorInterface::class);
expect($reporterParam->getType()->getName())
->toBe(ErrorReporterInterface::class);
});
});

View File

@@ -13,7 +13,8 @@ use App\Framework\Logging\HasChannel;
use App\Framework\Logging\LogChannel;
use App\Framework\Logging\Logger;
use App\Framework\Logging\SupportsChannels;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\Reflection\ReflectionService;
use App\Framework\Reflection\SimpleReflectionService;
use App\Framework\Config\Env as EnvAttribute;
use App\Framework\Config\EnvKey;
use App\Framework\Config\Environment;
@@ -116,8 +117,8 @@ final class ServiceWithEnvAttribute
beforeEach(function () {
$this->container = $this->createMock(Container::class);
$this->reflectionProvider = new CachedReflectionProvider();
$this->resolver = new ParameterResolver($this->container, $this->reflectionProvider);
$this->reflectionService = new SimpleReflectionService();
$this->resolver = new ParameterResolver($this->container, $this->reflectionService);
});
describe('ParameterResolver', function () {

View File

@@ -160,12 +160,12 @@ describe('SslCertificateHealthCheck', function () {
});
it('has correct name and category', function () {
expect($this->healthCheck->getName())->toBe('SSL Certificate');
expect($this->healthCheck->name)->toBe('SSL Certificate');
expect($this->healthCheck->getCategory())->toBe(HealthCheckCategory::SECURITY);
});
it('has reasonable timeout', function () {
$timeout = $this->healthCheck->getTimeout();
$timeout = $this->healthCheck->timeout;
expect($timeout)->toBeInt();
expect($timeout)->toBeGreaterThan(0);

View File

@@ -1,320 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling;
use App\Framework\ErrorHandling\ErrorHandlerManager;
use App\Framework\ErrorHandling\ErrorHandlerRegistry;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerInterface;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\ErrorHandling\Handlers\HandlerResult;
use App\Framework\Logging\Logger;
describe('ErrorHandlerManager', function () {
beforeEach(function () {
$this->registry = new ErrorHandlerRegistry();
$this->manager = new ErrorHandlerManager($this->registry);
});
it('executes handlers in priority order', function () {
$executionOrder = [];
$highPriorityHandler = new class ($executionOrder) implements ErrorHandlerInterface {
public function __construct(private array &$executionOrder) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->executionOrder[] = 'high';
return HandlerResult::create(
handled: true,
message: 'High priority handler'
);
}
public function getName(): string
{
return 'high_priority';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$lowPriorityHandler = new class ($executionOrder) implements ErrorHandlerInterface {
public function __construct(private array &$executionOrder) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->executionOrder[] = 'low';
return HandlerResult::create(
handled: true,
message: 'Low priority handler'
);
}
public function getName(): string
{
return 'low_priority';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::LOW;
}
};
$this->manager = $this->manager->register($lowPriorityHandler, $highPriorityHandler);
$exception = new \Exception('Test');
$this->manager->handle($exception);
expect($executionOrder)->toBe(['high', 'low']);
});
it('stops propagation when handler marks as final', function () {
$called = [];
$finalHandler = new class ($called) implements ErrorHandlerInterface {
public function __construct(private array &$called) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->called[] = 'final';
return HandlerResult::create(
handled: true,
message: 'Final handler',
isFinal: true
);
}
public function getName(): string
{
return 'final_handler';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$afterHandler = new class ($called) implements ErrorHandlerInterface {
public function __construct(private array &$called) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->called[] = 'after';
return HandlerResult::create(
handled: true,
message: 'After handler'
);
}
public function getName(): string
{
return 'after_handler';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::LOW;
}
};
$this->manager = $this->manager->register($finalHandler, $afterHandler);
$exception = new \Exception('Test');
$result = $this->manager->handle($exception);
expect($called)->toBe(['final']);
expect($result->handled)->toBeTrue();
});
it('skips handlers that cannot handle exception', function () {
$specificHandler = new class implements ErrorHandlerInterface {
public function canHandle(\Throwable $exception): bool
{
return $exception instanceof \InvalidArgumentException;
}
public function handle(\Throwable $exception): HandlerResult
{
return HandlerResult::create(
handled: true,
message: 'Specific handler'
);
}
public function getName(): string
{
return 'specific';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$this->manager = $this->manager->register($specificHandler);
$exception = new \RuntimeException('Test');
$result = $this->manager->handle($exception);
expect($result->handled)->toBeFalse();
expect($result->results)->toBeEmpty();
});
it('continues chain even if handler throws exception', function () {
$called = [];
$failingHandler = new class ($called) implements ErrorHandlerInterface {
public function __construct(private array &$called) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->called[] = 'failing';
throw new \RuntimeException('Handler failed');
}
public function getName(): string
{
return 'failing';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$workingHandler = new class ($called) implements ErrorHandlerInterface {
public function __construct(private array &$called) {}
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
$this->called[] = 'working';
return HandlerResult::create(
handled: true,
message: 'Working handler'
);
}
public function getName(): string
{
return 'working';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::LOW;
}
};
$this->manager = $this->manager->register($failingHandler, $workingHandler);
$exception = new \Exception('Test');
$result = $this->manager->handle($exception);
expect($called)->toBe(['failing', 'working']);
expect($result->handled)->toBeTrue();
});
it('aggregates results from multiple handlers', function () {
$handler1 = new class implements ErrorHandlerInterface {
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
return HandlerResult::create(
handled: true,
message: 'Handler 1',
data: ['from' => 'handler1']
);
}
public function getName(): string
{
return 'handler1';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::HIGH;
}
};
$handler2 = new class implements ErrorHandlerInterface {
public function canHandle(\Throwable $exception): bool
{
return true;
}
public function handle(\Throwable $exception): HandlerResult
{
return HandlerResult::create(
handled: true,
message: 'Handler 2',
data: ['from' => 'handler2']
);
}
public function getName(): string
{
return 'handler2';
}
public function getPriority(): ErrorHandlerPriority
{
return ErrorHandlerPriority::LOW;
}
};
$this->manager = $this->manager->register($handler1, $handler2);
$exception = new \Exception('Test');
$result = $this->manager->handle($exception);
expect($result->results)->toHaveCount(2);
expect($result->getMessages())->toBe(['Handler 1', 'Handler 2']);
$combinedData = $result->getCombinedData();
expect($combinedData)->toHaveKey('from');
});
});

View File

@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling\Handlers;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\ErrorHandling\Handlers\DatabaseErrorHandler;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\Logging\Logger;
describe('DatabaseErrorHandler', function () {
beforeEach(function () {
$this->logger = $this->createMock(Logger::class);
$this->handler = new DatabaseErrorHandler($this->logger);
});
it('handles DatabaseException', function () {
$exception = DatabaseException::fromContext(
'Connection failed',
\App\Framework\Exception\ExceptionContext::empty()
);
expect($this->handler->canHandle($exception))->toBeTrue();
$this->logger
->expects($this->once())
->method('error')
->with('Database error occurred', $this->anything());
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->statusCode)->toBe(500);
expect($result->data['error_type'])->toBe('database');
expect($result->data['retry_after'])->toBe(60);
});
it('handles PDOException', function () {
$exception = new \PDOException('SQLSTATE[HY000] [2002] Connection refused');
expect($this->handler->canHandle($exception))->toBeTrue();
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->statusCode)->toBe(500);
});
it('does not handle non-database exceptions', function () {
$exception = new \RuntimeException('Some error');
expect($this->handler->canHandle($exception))->toBeFalse();
});
it('has HIGH priority', function () {
expect($this->handler->getPriority())->toBe(ErrorHandlerPriority::HIGH);
});
it('has correct name', function () {
expect($this->handler->getName())->toBe('database_error_handler');
});
});

View File

@@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling\Handlers;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\ErrorHandling\Handlers\FallbackErrorHandler;
use App\Framework\Logging\Logger;
describe('FallbackErrorHandler', function () {
beforeEach(function () {
$this->logger = $this->createMock(Logger::class);
$this->handler = new FallbackErrorHandler($this->logger);
});
it('handles any exception', function () {
$exception = new \RuntimeException('Any error');
expect($this->handler->canHandle($exception))->toBeTrue();
});
it('logs exception with full context', function () {
$exception = new \RuntimeException('Test error');
$this->logger
->expects($this->once())
->method('error')
->with('Unhandled exception', $this->callback(function ($context) use ($exception) {
return $context instanceof \App\Framework\Logging\ValueObjects\LogContext
&& $context->structured['exception_class'] === \RuntimeException::class
&& $context->structured['message'] === 'Test error'
&& isset($context->structured['file'])
&& isset($context->structured['line'])
&& isset($context->structured['trace']);
}));
$this->handler->handle($exception);
});
it('returns generic error message', function () {
$exception = new \RuntimeException('Detailed error');
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->message)->toBe('An unexpected error occurred');
expect($result->isFinal)->toBeTrue();
expect($result->statusCode)->toBe(500);
expect($result->data['error_type'])->toBe('unhandled');
expect($result->data['exception_class'])->toBe(\RuntimeException::class);
});
it('marks result as final', function () {
$exception = new \RuntimeException('Test');
$result = $this->handler->handle($exception);
expect($result->isFinal)->toBeTrue();
});
it('has LOWEST priority', function () {
expect($this->handler->getPriority())->toBe(ErrorHandlerPriority::LOWEST);
});
it('has correct name', function () {
expect($this->handler->getName())->toBe('fallback_error_handler');
});
});

View File

@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling\Handlers;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\ErrorHandling\Handlers\HttpErrorHandler;
use App\Framework\Http\Exception\HttpException;
use App\Framework\Http\Status;
describe('HttpErrorHandler', function () {
beforeEach(function () {
$this->handler = new HttpErrorHandler();
});
it('handles HttpException', function () {
$exception = new HttpException(
'Not Found',
Status::NOT_FOUND,
headers: ['X-Custom' => 'value']
);
expect($this->handler->canHandle($exception))->toBeTrue();
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->message)->toBe('Not Found');
expect($result->statusCode)->toBe(404);
expect($result->data['error_type'])->toBe('http');
expect($result->data['headers'])->toBe(['X-Custom' => 'value']);
});
it('handles HttpException with no headers', function () {
$exception = new HttpException(
'Bad Request',
Status::BAD_REQUEST
);
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->statusCode)->toBe(400);
expect($result->data['headers'])->toBe([]);
});
it('does not handle non-HttpException', function () {
$exception = new \RuntimeException('Some error');
expect($this->handler->canHandle($exception))->toBeFalse();
});
it('has NORMAL priority', function () {
expect($this->handler->getPriority())->toBe(ErrorHandlerPriority::NORMAL);
});
it('has correct name', function () {
expect($this->handler->getName())->toBe('http_error_handler');
});
});

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\ErrorHandling\Handlers;
use App\Framework\ErrorHandling\Handlers\ErrorHandlerPriority;
use App\Framework\ErrorHandling\Handlers\ValidationErrorHandler;
use App\Framework\Validation\Exceptions\ValidationException;
describe('ValidationErrorHandler', function () {
beforeEach(function () {
$this->handler = new ValidationErrorHandler();
});
it('handles ValidationException', function () {
$validationResult = new \App\Framework\Validation\ValidationResult();
$validationResult->addErrors('email', ['Email is required', 'Email format is invalid']);
$validationResult->addErrors('password', ['Password must be at least 8 characters']);
$exception = new ValidationException($validationResult);
expect($this->handler->canHandle($exception))->toBeTrue();
$result = $this->handler->handle($exception);
expect($result->handled)->toBeTrue();
expect($result->statusCode)->toBe(422);
expect($result->data)->toHaveKey('errors');
expect($result->data['errors'])->toBe($validationResult->getAll());
expect($result->data['error_type'])->toBe('validation');
});
it('does not handle non-ValidationException', function () {
$exception = new \RuntimeException('Some error');
expect($this->handler->canHandle($exception))->toBeFalse();
});
it('has CRITICAL priority', function () {
expect($this->handler->getPriority())->toBe(ErrorHandlerPriority::CRITICAL);
});
it('has correct name', function () {
expect($this->handler->getName())->toBe('validation_error_handler');
});
});

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
use App\Framework\Audit\AuditLogger;
use App\Framework\Audit\InMemoryAuditLogger;
use App\Framework\Audit\ValueObjects\AuditEntry;
use App\Framework\Audit\ValueObjects\AuditableAction;
use App\Framework\DateTime\Clock;
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
describe('ExceptionAuditLogger', function () {
beforeEach(function () {
$this->auditLogger = new InMemoryAuditLogger();
$this->clock = new class implements Clock {
private DateTimeImmutable $now;
public function __construct()
{
$this->now = new DateTimeImmutable('2024-01-01 12:00:00');
}
public function now(): DateTimeImmutable
{
return $this->now;
}
public function fromTimestamp(\App\Framework\Core\ValueObjects\Timestamp $timestamp): DateTimeImmutable
{
return DateTimeImmutable::createFromTimestamp($timestamp->value);
}
public function fromString(string $dateTime, ?string $format = null): DateTimeImmutable
{
return DateTimeImmutable::createFromFormat($format ?? 'Y-m-d H:i:s', $dateTime);
}
public function today(): DateTimeImmutable
{
return $this->now;
}
public function yesterday(): DateTimeImmutable
{
return $this->now->modify('-1 day');
}
public function tomorrow(): DateTimeImmutable
{
return $this->now->modify('+1 day');
}
public function time(): \App\Framework\Core\ValueObjects\Timestamp
{
return new \App\Framework\Core\ValueObjects\Timestamp($this->now->getTimestamp());
}
};
$this->contextProvider = new ExceptionContextProvider();
$this->exceptionAuditLogger = new ExceptionAuditLogger(
$this->auditLogger,
$this->clock,
$this->contextProvider
);
});
it('logs auditable exception as audit entry', function () {
$exception = new RuntimeException('User not found');
$context = ExceptionContextData::forOperation('user.lookup', 'UserRepository')
->addData(['user_id' => '123'])
->withUserId('user-456')
->withAuditable(true);
$this->contextProvider->attach($exception, $context);
$this->exceptionAuditLogger->logIfAuditable($exception);
// Check that audit entry was created
$entries = $this->getAllAuditEntries($this->auditLogger);
expect($entries)->toHaveCount(1);
$entry = $entries[0];
expect($entry->success)->toBeFalse();
expect($entry->errorMessage)->toBe('User not found');
expect($entry->userId)->toBe('user-456');
expect($entry->entityType)->toBe('userrepository');
expect($entry->action)->toBeInstanceOf(AuditableAction::class);
});
it('does not log non-auditable exception', function () {
$exception = new RuntimeException('Validation error');
$context = ExceptionContextData::forOperation('validation.check')
->withAuditable(false);
$this->contextProvider->attach($exception, $context);
$this->exceptionAuditLogger->logIfAuditable($exception);
// Check that no audit entry was created
$entries = $this->getAllAuditEntries($this->auditLogger);
expect($entries)->toHaveCount(0);
});
it('logs exception without context as auditable by default', function () {
$exception = new RuntimeException('Generic error');
$this->exceptionAuditLogger->logIfAuditable($exception);
// Check that audit entry was created (default: auditable)
$entries = $this->getAllAuditEntries($this->auditLogger);
expect($entries)->toHaveCount(1);
});
it('includes context data in audit entry metadata', function () {
$exception = new RuntimeException('Operation failed');
$context = ExceptionContextData::forOperation('order.process', 'OrderService')
->addData(['order_id' => 'order-789', 'amount' => 1000])
->withUserId('user-123')
->withRequestId('req-456')
->withTags('payment', 'external_api');
$this->contextProvider->attach($exception, $context);
$this->exceptionAuditLogger->logIfAuditable($exception);
$entries = $this->getAllAuditEntries($this->auditLogger);
$entry = $entries[0];
expect($entry->metadata)->toHaveKey('operation');
expect($entry->metadata['operation'])->toBe('order.process');
expect($entry->metadata)->toHaveKey('component');
expect($entry->metadata['component'])->toBe('OrderService');
expect($entry->metadata)->toHaveKey('context_data');
expect($entry->metadata['context_data']['order_id'])->toBe('order-789');
expect($entry->metadata)->toHaveKey('tags');
expect($entry->metadata['tags'])->toBe(['payment', 'external_api']);
});
it('determines audit action from operation name', function () {
$testCases = [
['operation' => 'user.create', 'expected' => AuditableAction::CREATE],
['operation' => 'order.update', 'expected' => AuditableAction::UPDATE],
['operation' => 'product.delete', 'expected' => AuditableAction::DELETE],
['operation' => 'data.read', 'expected' => AuditableAction::READ],
];
foreach ($testCases as $testCase) {
$exception = new RuntimeException('Test');
$context = ExceptionContextData::forOperation($testCase['operation'])
->withAuditable(true);
$this->contextProvider->attach($exception, $context);
$this->exceptionAuditLogger->logIfAuditable($exception);
$entries = $this->getAllAuditEntries($this->auditLogger);
$entry = end($entries);
expect($entry->action)->toBe($testCase['expected']);
}
});
it('extracts entity ID from context data', function () {
$exception = new RuntimeException('Entity not found');
$context = ExceptionContextData::forOperation('entity.get')
->addData(['entity_id' => 'entity-123'])
->withAuditable(true);
$this->contextProvider->attach($exception, $context);
$this->exceptionAuditLogger->logIfAuditable($exception);
$entries = $this->getAllAuditEntries($this->auditLogger);
$entry = $entries[0];
expect($entry->entityId)->toBe('entity-123');
});
it('handles IP address and user agent from context with Value Objects', function () {
$exception = new RuntimeException('Security violation');
$context = ExceptionContextData::forOperation('security.check')
->withClientIp(\App\Framework\Http\IpAddress::from('192.168.1.1'))
->withUserAgent(\App\Framework\UserAgent\UserAgent::fromString('Mozilla/5.0'))
->withAuditable(true);
$this->contextProvider->attach($exception, $context);
$this->exceptionAuditLogger->logIfAuditable($exception);
$entries = $this->getAllAuditEntries($this->auditLogger);
$entry = $entries[0];
expect($entry->ipAddress)->not->toBeNull();
expect((string) $entry->ipAddress)->toBe('192.168.1.1');
expect($entry->userAgent)->not->toBeNull();
expect($entry->userAgent->value)->toBe('Mozilla/5.0');
});
it('handles IP address and user agent from context with strings (backward compatibility)', function () {
$exception = new RuntimeException('Security violation');
$context = ExceptionContextData::forOperation('security.check')
->withClientIp('192.168.1.1')
->withUserAgent('Mozilla/5.0')
->withAuditable(true);
$this->contextProvider->attach($exception, $context);
$this->exceptionAuditLogger->logIfAuditable($exception);
$entries = $this->getAllAuditEntries($this->auditLogger);
$entry = $entries[0];
expect($entry->ipAddress)->not->toBeNull();
expect((string) $entry->ipAddress)->toBe('192.168.1.1');
expect($entry->userAgent)->not->toBeNull();
expect($entry->userAgent->value)->toBe('Mozilla/5.0');
});
it('does not throw when audit logging fails', function () {
$failingAuditLogger = new class implements AuditLogger {
public function log(AuditEntry $entry): void
{
throw new RuntimeException('Audit logging failed');
}
public function find(\App\Framework\Audit\ValueObjects\AuditId $id): ?AuditEntry
{
return null;
}
public function query(\App\Framework\Audit\ValueObjects\AuditQuery $query): array
{
return [];
}
public function count(\App\Framework\Audit\ValueObjects\AuditQuery $query): int
{
return 0;
}
public function purgeOlderThan(DateTimeImmutable $date): int
{
return 0;
}
};
$logger = new ExceptionAuditLogger(
$failingAuditLogger,
$this->clock,
$this->contextProvider
);
$exception = new RuntimeException('Test error');
$context = ExceptionContextData::forOperation('test')
->withAuditable(true);
$this->contextProvider->attach($exception, $context);
// Should not throw
expect(fn() => $logger->logIfAuditable($exception))->not->toThrow();
});
it('includes previous exception in metadata', function () {
$previous = new InvalidArgumentException('Invalid input');
$exception = new RuntimeException('Operation failed', 0, $previous);
$context = ExceptionContextData::forOperation('operation.execute')
->withAuditable(true);
$this->contextProvider->attach($exception, $context);
$this->exceptionAuditLogger->logIfAuditable($exception);
$entries = $this->getAllAuditEntries($this->auditLogger);
$entry = $entries[0];
expect($entry->metadata)->toHaveKey('previous_exception');
expect($entry->metadata['previous_exception']['class'])->toBe(InvalidArgumentException::class);
expect($entry->metadata['previous_exception']['message'])->toBe('Invalid input');
});
});
/**
* Helper to get all audit entries from InMemoryAuditLogger
*
* @return array<AuditEntry>
*/
function getAllAuditEntries(AuditLogger $logger): array
{
if ($logger instanceof InMemoryAuditLogger) {
// Use reflection to access private entries property
$reflection = new ReflectionClass($logger);
$property = $reflection->getProperty('entries');
$property->setAccessible(true);
return array_values($property->getValue($logger));
}
// For other implementations, query all entries
$query = new \App\Framework\Audit\ValueObjects\AuditQuery();
return $logger->query($query);
}

View File

@@ -174,6 +174,36 @@ describe('Exception Context Integration', function () {
expect($context2->data['query'])->toBe('SELECT * FROM users');
});
it('serializes Value Objects to strings in toArray()', function () {
$context = ExceptionContextData::forOperation('test.operation')
->withClientIp(\App\Framework\Http\IpAddress::from('192.168.1.1'))
->withUserAgent(\App\Framework\UserAgent\UserAgent::fromString('Mozilla/5.0'))
->withSessionId(\App\Framework\Http\Session\SessionId::fromString('session-12345678901234567890123456789012'))
->withRequestId('req-123');
$array = $context->toArray();
expect($array['client_ip'])->toBe('192.168.1.1');
expect($array['user_agent'])->toBe('Mozilla/5.0');
expect($array['session_id'])->toBe('session-12345678901234567890123456789012');
expect($array['request_id'])->toBe('req-123');
});
it('serializes strings to strings in toArray() (backward compatibility)', function () {
$context = ExceptionContextData::forOperation('test.operation')
->withClientIp('192.168.1.1')
->withUserAgent('Mozilla/5.0')
->withSessionId('session-123')
->withRequestId('req-123');
$array = $context->toArray();
expect($array['client_ip'])->toBe('192.168.1.1');
expect($array['user_agent'])->toBe('Mozilla/5.0');
expect($array['session_id'])->toBe('session-123');
expect($array['request_id'])->toBe('req-123');
});
it('handles nested error scopes correctly', function () {
// Outer scope
$outerScope = ErrorScopeContext::http(

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
use App\Framework\String\ValueObjects\PosixAlphaString;
use App\Framework\String\ValueObjects\TypedString;
use App\Framework\String\ValueObjects\PosixString;
describe('PosixAlphaString', function () {
describe('Validation', function () {
it('accepts valid alphabetic strings', function () {
$alpha1 = PosixAlphaString::fromString('abcdefghijklmnopqrstuvwxyz');
$alpha2 = PosixAlphaString::fromString('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
$alpha3 = PosixAlphaString::fromString('HelloWorld');
expect($alpha1->value)->toBe('abcdefghijklmnopqrstuvwxyz');
expect($alpha2->value)->toBe('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
expect($alpha3->value)->toBe('HelloWorld');
});
it('rejects strings with digits', function () {
PosixAlphaString::fromString('abc123');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX alphabetic characters');
it('rejects strings with punctuation', function () {
PosixAlphaString::fromString('hello!world');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX alphabetic characters');
it('rejects strings with whitespace', function () {
PosixAlphaString::fromString('hello world');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX alphabetic characters');
it('rejects strings with hyphens', function () {
PosixAlphaString::fromString('hello-world');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX alphabetic characters');
it('rejects strings with underscores', function () {
PosixAlphaString::fromString('hello_world');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX alphabetic characters');
it('rejects empty strings', function () {
PosixAlphaString::fromString('');
})->throws(\InvalidArgumentException::class, 'String must not be empty');
it('rejects strings with special characters', function () {
PosixAlphaString::fromString('hello@world');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX alphabetic characters');
});
describe('International Character Support', function () {
it('validates German characters with locale', function () {
// Note: Requires locale to be set
// setlocale(LC_CTYPE, 'de_DE.UTF-8');
$german1 = PosixAlphaString::fromString('Müller');
$german2 = PosixAlphaString::fromString('Schön');
$german3 = PosixAlphaString::fromString('Größe');
expect($german1->value)->toBe('Müller');
expect($german2->value)->toBe('Schön');
expect($german3->value)->toBe('Größe');
})->skip('Requires locale configuration');
it('validates French characters with locale', function () {
// Note: Requires locale to be set
// setlocale(LC_CTYPE, 'fr_FR.UTF-8');
$french1 = PosixAlphaString::fromString('Dupré');
$french2 = PosixAlphaString::fromString('Château');
$french3 = PosixAlphaString::fromString('Garçon');
expect($french1->value)->toBe('Dupré');
expect($french2->value)->toBe('Château');
expect($french3->value)->toBe('Garçon');
})->skip('Requires locale configuration');
it('validates Spanish characters with locale', function () {
// Note: Requires locale to be set
// setlocale(LC_CTYPE, 'es_ES.UTF-8');
$spanish1 = PosixAlphaString::fromString('Señor');
$spanish2 = PosixAlphaString::fromString('Niño');
$spanish3 = PosixAlphaString::fromString('José');
expect($spanish1->value)->toBe('Señor');
expect($spanish2->value)->toBe('Niño');
expect($spanish3->value)->toBe('José');
})->skip('Requires locale configuration');
});
describe('Conversion Methods', function () {
it('converts to TypedString', function () {
$alpha = PosixAlphaString::fromString('Hello');
$typed = $alpha->toTypedString();
expect($typed)->toBeInstanceOf(TypedString::class);
expect($typed->value)->toBe('Hello');
expect($typed->isAlphabetic())->toBeTrue();
});
it('converts to PosixString', function () {
$alpha = PosixAlphaString::fromString('World');
$posix = $alpha->toPosixString();
expect($posix)->toBeInstanceOf(PosixString::class);
expect($posix->value)->toBe('World');
expect($posix->isAlpha())->toBeTrue();
});
it('converts to string', function () {
$alpha = PosixAlphaString::fromString('Test');
expect($alpha->toString())->toBe('Test');
expect((string) $alpha)->toBe('Test');
});
});
describe('Comparison Methods', function () {
it('checks equality', function () {
$alpha1 = PosixAlphaString::fromString('Hello');
$alpha2 = PosixAlphaString::fromString('Hello');
$alpha3 = PosixAlphaString::fromString('World');
expect($alpha1->equals($alpha2))->toBeTrue();
expect($alpha1->equals($alpha3))->toBeFalse();
});
it('compares case-sensitive', function () {
$lower = PosixAlphaString::fromString('hello');
$upper = PosixAlphaString::fromString('HELLO');
expect($lower->equals($upper))->toBeFalse();
});
});
describe('Real World Use Cases', function () {
it('validates first names', function () {
$firstName1 = PosixAlphaString::fromString('John');
$firstName2 = PosixAlphaString::fromString('Mary');
$firstName3 = PosixAlphaString::fromString('Alexander');
expect($firstName1->value)->toBe('John');
expect($firstName2->value)->toBe('Mary');
expect($firstName3->value)->toBe('Alexander');
});
it('validates last names', function () {
$lastName1 = PosixAlphaString::fromString('Smith');
$lastName2 = PosixAlphaString::fromString('Johnson');
$lastName3 = PosixAlphaString::fromString('Williams');
expect($lastName1->value)->toBe('Smith');
expect($lastName2->value)->toBe('Johnson');
expect($lastName3->value)->toBe('Williams');
});
it('rejects names with numbers', function () {
PosixAlphaString::fromString('John123');
})->throws(\InvalidArgumentException::class);
it('rejects compound names with hyphens', function () {
// Note: Real-world compound names like "Jean-Pierre" would need
// a different validation strategy or preprocessing
PosixAlphaString::fromString('Jean-Pierre');
})->throws(\InvalidArgumentException::class);
it('rejects names with titles', function () {
PosixAlphaString::fromString('Dr. Smith');
})->throws(\InvalidArgumentException::class);
});
describe('Single Character Strings', function () {
it('accepts single letter', function () {
$a = PosixAlphaString::fromString('A');
$z = PosixAlphaString::fromString('z');
expect($a->value)->toBe('A');
expect($z->value)->toBe('z');
});
it('rejects single digit', function () {
PosixAlphaString::fromString('5');
})->throws(\InvalidArgumentException::class);
it('rejects single special character', function () {
PosixAlphaString::fromString('!');
})->throws(\InvalidArgumentException::class);
});
describe('Edge Cases', function () {
it('handles very long alphabetic strings', function () {
$longString = str_repeat('abcdefghijklmnopqrstuvwxyz', 100);
$alpha = PosixAlphaString::fromString($longString);
expect($alpha->value)->toBe($longString);
expect(strlen($alpha->value))->toBe(2600);
});
it('handles mixed case correctly', function () {
$mixed = PosixAlphaString::fromString('AbCdEfGhIjKlMnOpQrStUvWxYz');
expect($mixed->value)->toBe('AbCdEfGhIjKlMnOpQrStUvWxYz');
});
it('rejects numeric-looking strings', function () {
PosixAlphaString::fromString('one23');
})->throws(\InvalidArgumentException::class);
});
});

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
use App\Framework\String\ValueObjects\PosixString;
describe('PosixString', function () {
describe('Factory Method', function () {
it('creates from string', function () {
$str = PosixString::fromString('test123');
expect($str->value)->toBe('test123');
});
});
describe('POSIX Character Class Validators', function () {
it('validates alnum (alphanumeric)', function () {
$valid = PosixString::fromString('abc123XYZ');
$invalid = PosixString::fromString('abc-123');
expect($valid->isAlnum())->toBeTrue();
expect($invalid->isAlnum())->toBeFalse();
});
it('validates alpha (alphabetic)', function () {
$valid = PosixString::fromString('abcXYZ');
$invalid = PosixString::fromString('abc123');
expect($valid->isAlpha())->toBeTrue();
expect($invalid->isAlpha())->toBeFalse();
});
it('validates alpha with international characters (locale-aware)', function () {
// Note: Locale must be set for international character support
// setlocale(LC_CTYPE, 'de_DE.UTF-8');
$german = PosixString::fromString('Müller');
$french = PosixString::fromString('Dupré');
$spanish = PosixString::fromString('Señor');
// These work if locale is set
// expect($german->isAlpha())->toBeTrue();
// expect($french->isAlpha())->toBeTrue();
// expect($spanish->isAlpha())->toBeTrue();
})->skip('Requires locale configuration');
it('validates ascii', function () {
$valid = PosixString::fromString('Hello World!');
$invalid = PosixString::fromString('Hëllö Wörld!');
expect($valid->isAscii())->toBeTrue();
expect($invalid->isAscii())->toBeFalse();
});
it('validates blank (space and tab only)', function () {
$valid = PosixString::fromString(" \t ");
$invalid = PosixString::fromString(" \n ");
expect($valid->isBlank())->toBeTrue();
expect($invalid->isBlank())->toBeFalse();
});
it('validates cntrl (control characters)', function () {
$valid = PosixString::fromString("\x00\x01\x02");
$invalid = PosixString::fromString("abc");
expect($valid->isCntrl())->toBeTrue();
expect($invalid->isCntrl())->toBeFalse();
});
it('validates digit', function () {
$valid = PosixString::fromString('123456');
$invalid = PosixString::fromString('123abc');
expect($valid->isDigit())->toBeTrue();
expect($invalid->isDigit())->toBeFalse();
});
it('validates graph (visible characters, excluding space)', function () {
$valid = PosixString::fromString('Hello!');
$invalid = PosixString::fromString('Hello World');
expect($valid->isGraph())->toBeTrue();
expect($invalid->isGraph())->toBeFalse();
});
it('validates lower (lowercase)', function () {
$valid = PosixString::fromString('hello');
$invalid = PosixString::fromString('Hello');
expect($valid->isLower())->toBeTrue();
expect($invalid->isLower())->toBeFalse();
});
it('validates print (printable, including space)', function () {
$valid = PosixString::fromString('Hello World!');
$invalid = PosixString::fromString("Hello\x00World");
expect($valid->isPrint())->toBeTrue();
expect($invalid->isPrint())->toBeFalse();
});
it('validates punct (punctuation)', function () {
$valid = PosixString::fromString('!@#$%^&*()');
$invalid = PosixString::fromString('abc!@#');
expect($valid->isPunct())->toBeTrue();
expect($invalid->isPunct())->toBeFalse();
});
it('validates space (whitespace)', function () {
$valid = PosixString::fromString(" \t\n\r");
$invalid = PosixString::fromString('abc');
expect($valid->isSpace())->toBeTrue();
expect($invalid->isSpace())->toBeFalse();
});
it('validates upper (uppercase)', function () {
$valid = PosixString::fromString('HELLO');
$invalid = PosixString::fromString('Hello');
expect($valid->isUpper())->toBeTrue();
expect($invalid->isUpper())->toBeFalse();
});
it('validates xdigit (hexadecimal)', function () {
$valid = PosixString::fromString('0123456789abcdefABCDEF');
$invalid = PosixString::fromString('0123xyz');
expect($valid->isXdigit())->toBeTrue();
expect($invalid->isXdigit())->toBeFalse();
});
it('validates word (alphanumeric + underscore)', function () {
$valid = PosixString::fromString('user_name123');
$invalid = PosixString::fromString('user-name');
expect($valid->isWord())->toBeTrue();
expect($invalid->isWord())->toBeFalse();
});
});
describe('Helper Methods', function () {
it('checks if string contains POSIX class characters', function () {
$str = PosixString::fromString('hello123world');
expect($str->containsPosixClass('alpha'))->toBeTrue();
expect($str->containsPosixClass('digit'))->toBeTrue();
expect($str->containsPosixClass('punct'))->toBeFalse();
});
it('counts POSIX class characters', function () {
$str = PosixString::fromString('abc123xyz789');
expect($str->countPosixClass('alpha'))->toBe(6);
expect($str->countPosixClass('digit'))->toBe(6);
expect($str->countPosixClass('punct'))->toBe(0);
});
it('filters by POSIX class', function () {
$str = PosixString::fromString('abc123xyz');
$alphaOnly = $str->filterByPosixClass('alpha');
$digitOnly = $str->filterByPosixClass('digit');
expect($alphaOnly->value)->toBe('abcxyz');
expect($digitOnly->value)->toBe('123');
});
});
describe('Conversion Methods', function () {
it('converts to TypedString', function () {
$posix = PosixString::fromString('test');
$typed = $posix->toTypedString();
expect($typed->value)->toBe('test');
expect($typed->isAlphabetic())->toBeTrue();
});
it('converts to string', function () {
$str = PosixString::fromString('hello');
expect($str->toString())->toBe('hello');
expect((string) $str)->toBe('hello');
});
});
describe('Comparison Methods', function () {
it('checks equality', function () {
$str1 = PosixString::fromString('test');
$str2 = PosixString::fromString('test');
$str3 = PosixString::fromString('other');
expect($str1->equals($str2))->toBeTrue();
expect($str1->equals($str3))->toBeFalse();
});
it('checks if empty', function () {
$empty = PosixString::fromString('');
$notEmpty = PosixString::fromString('test');
expect($empty->isEmpty())->toBeTrue();
expect($notEmpty->isEmpty())->toBeFalse();
});
it('checks if not empty', function () {
$empty = PosixString::fromString('');
$notEmpty = PosixString::fromString('test');
expect($empty->isNotEmpty())->toBeFalse();
expect($notEmpty->isNotEmpty())->toBeTrue();
});
});
describe('Edge Cases', function () {
it('handles empty string validation', function () {
$empty = PosixString::fromString('');
expect($empty->isAlpha())->toBeFalse();
expect($empty->isAlnum())->toBeFalse();
expect($empty->isDigit())->toBeFalse();
});
it('handles single character validation', function () {
$alpha = PosixString::fromString('a');
$digit = PosixString::fromString('5');
$punct = PosixString::fromString('!');
expect($alpha->isAlpha())->toBeTrue();
expect($digit->isDigit())->toBeTrue();
expect($punct->isPunct())->toBeTrue();
});
it('handles mixed character sets', function () {
$mixed = PosixString::fromString('abc123!@#');
expect($mixed->isAlnum())->toBeFalse(); // Contains punctuation
expect($mixed->containsPosixClass('alpha'))->toBeTrue();
expect($mixed->containsPosixClass('digit'))->toBeTrue();
expect($mixed->containsPosixClass('punct'))->toBeTrue();
});
});
describe('Real World Use Cases', function () {
it('validates usernames', function () {
$validUsername = PosixString::fromString('john_doe_123');
$invalidUsername = PosixString::fromString('john-doe@example');
expect($validUsername->isWord())->toBeTrue();
expect($invalidUsername->isWord())->toBeFalse();
});
it('validates identifiers', function () {
$validId = PosixString::fromString('MAX_TIMEOUT_VALUE');
$invalidId = PosixString::fromString('max-timeout-value');
expect($validId->isWord())->toBeTrue();
expect($invalidId->isWord())->toBeFalse();
});
it('validates hexadecimal color codes', function () {
$validColor = PosixString::fromString('FF5733');
$invalidColor = PosixString::fromString('GG5733');
expect($validColor->isXdigit())->toBeTrue();
expect($invalidColor->isXdigit())->toBeFalse();
});
it('validates numeric strings', function () {
$validNum = PosixString::fromString('123456');
$invalidNum = PosixString::fromString('12.34');
expect($validNum->isDigit())->toBeTrue();
expect($invalidNum->isDigit())->toBeFalse();
});
});
});

View File

@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
use App\Framework\String\ValueObjects\PosixWordString;
use App\Framework\String\ValueObjects\TypedString;
use App\Framework\String\ValueObjects\PosixString;
describe('PosixWordString', function () {
describe('Validation', function () {
it('accepts valid word strings', function () {
$word1 = PosixWordString::fromString('hello');
$word2 = PosixWordString::fromString('Hello123');
$word3 = PosixWordString::fromString('user_name');
$word4 = PosixWordString::fromString('MAX_VALUE');
$word5 = PosixWordString::fromString('_private');
expect($word1->value)->toBe('hello');
expect($word2->value)->toBe('Hello123');
expect($word3->value)->toBe('user_name');
expect($word4->value)->toBe('MAX_VALUE');
expect($word5->value)->toBe('_private');
});
it('rejects strings with hyphens', function () {
PosixWordString::fromString('user-name');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX word characters');
it('rejects strings with spaces', function () {
PosixWordString::fromString('hello world');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX word characters');
it('rejects strings with special characters', function () {
PosixWordString::fromString('hello@world');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX word characters');
it('rejects strings with punctuation', function () {
PosixWordString::fromString('hello.world');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX word characters');
it('rejects strings with operators', function () {
PosixWordString::fromString('a+b');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX word characters');
it('rejects empty strings', function () {
PosixWordString::fromString('');
})->throws(\InvalidArgumentException::class, 'String must not be empty');
it('rejects strings with parentheses', function () {
PosixWordString::fromString('function()');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX word characters');
it('rejects strings with brackets', function () {
PosixWordString::fromString('array[0]');
})->throws(\InvalidArgumentException::class, 'String must contain only POSIX word characters');
});
describe('Conversion Methods', function () {
it('converts to TypedString', function () {
$word = PosixWordString::fromString('user_id');
$typed = $word->toTypedString();
expect($typed)->toBeInstanceOf(TypedString::class);
expect($typed->value)->toBe('user_id');
expect($typed->isAlphanumeric())->toBeFalse(); // Contains underscore
});
it('converts to PosixString', function () {
$word = PosixWordString::fromString('MAX_VALUE');
$posix = $word->toPosixString();
expect($posix)->toBeInstanceOf(PosixString::class);
expect($posix->value)->toBe('MAX_VALUE');
expect($posix->isWord())->toBeTrue();
});
it('converts to string', function () {
$word = PosixWordString::fromString('username');
expect($word->toString())->toBe('username');
expect((string) $word)->toBe('username');
});
});
describe('Comparison Methods', function () {
it('checks equality', function () {
$word1 = PosixWordString::fromString('user_id');
$word2 = PosixWordString::fromString('user_id');
$word3 = PosixWordString::fromString('user_name');
expect($word1->equals($word2))->toBeTrue();
expect($word1->equals($word3))->toBeFalse();
});
it('compares case-sensitive', function () {
$lower = PosixWordString::fromString('username');
$upper = PosixWordString::fromString('USERNAME');
expect($lower->equals($upper))->toBeFalse();
});
});
describe('Real World Use Cases', function () {
describe('PHP Identifiers', function () {
it('validates variable names', function () {
$var1 = PosixWordString::fromString('userName');
$var2 = PosixWordString::fromString('user_id');
$var3 = PosixWordString::fromString('_privateVar');
expect($var1->value)->toBe('userName');
expect($var2->value)->toBe('user_id');
expect($var3->value)->toBe('_privateVar');
});
it('validates constant names', function () {
$const1 = PosixWordString::fromString('MAX_SIZE');
$const2 = PosixWordString::fromString('API_KEY');
$const3 = PosixWordString::fromString('DEFAULT_TIMEOUT');
expect($const1->value)->toBe('MAX_SIZE');
expect($const2->value)->toBe('API_KEY');
expect($const3->value)->toBe('DEFAULT_TIMEOUT');
});
it('validates function names', function () {
$func1 = PosixWordString::fromString('calculateTotal');
$func2 = PosixWordString::fromString('get_user_by_id');
$func3 = PosixWordString::fromString('__construct');
expect($func1->value)->toBe('calculateTotal');
expect($func2->value)->toBe('get_user_by_id');
expect($func3->value)->toBe('__construct');
});
it('rejects invalid PHP identifiers', function () {
PosixWordString::fromString('my-function');
})->throws(\InvalidArgumentException::class);
});
describe('Database Column Names', function () {
it('validates snake_case column names', function () {
$col1 = PosixWordString::fromString('user_id');
$col2 = PosixWordString::fromString('created_at');
$col3 = PosixWordString::fromString('email_verified_at');
expect($col1->value)->toBe('user_id');
expect($col2->value)->toBe('created_at');
expect($col3->value)->toBe('email_verified_at');
});
it('rejects kebab-case column names', function () {
PosixWordString::fromString('user-id');
})->throws(\InvalidArgumentException::class);
});
describe('Usernames', function () {
it('validates valid usernames', function () {
$user1 = PosixWordString::fromString('john_doe');
$user2 = PosixWordString::fromString('user123');
$user3 = PosixWordString::fromString('admin_user');
expect($user1->value)->toBe('john_doe');
expect($user2->value)->toBe('user123');
expect($user3->value)->toBe('admin_user');
});
it('rejects usernames with special characters', function () {
PosixWordString::fromString('john@doe');
})->throws(\InvalidArgumentException::class);
it('rejects usernames with hyphens', function () {
PosixWordString::fromString('john-doe');
})->throws(\InvalidArgumentException::class);
it('rejects usernames with spaces', function () {
PosixWordString::fromString('john doe');
})->throws(\InvalidArgumentException::class);
});
describe('URL Slugs (with underscore)', function () {
it('validates underscore slugs', function () {
$slug1 = PosixWordString::fromString('my_blog_post');
$slug2 = PosixWordString::fromString('product_category_1');
$slug3 = PosixWordString::fromString('about_us');
expect($slug1->value)->toBe('my_blog_post');
expect($slug2->value)->toBe('product_category_1');
expect($slug3->value)->toBe('about_us');
});
it('rejects hyphenated slugs', function () {
// Note: Hyphenated slugs need different validation
PosixWordString::fromString('my-blog-post');
})->throws(\InvalidArgumentException::class);
});
});
describe('Single Character Strings', function () {
it('accepts single letter', function () {
$a = PosixWordString::fromString('a');
$Z = PosixWordString::fromString('Z');
expect($a->value)->toBe('a');
expect($Z->value)->toBe('Z');
});
it('accepts single digit', function () {
$five = PosixWordString::fromString('5');
expect($five->value)->toBe('5');
});
it('accepts single underscore', function () {
$underscore = PosixWordString::fromString('_');
expect($underscore->value)->toBe('_');
});
it('rejects single special character', function () {
PosixWordString::fromString('!');
})->throws(\InvalidArgumentException::class);
it('rejects single hyphen', function () {
PosixWordString::fromString('-');
})->throws(\InvalidArgumentException::class);
});
describe('Edge Cases', function () {
it('handles very long word strings', function () {
$longString = str_repeat('abc123_', 100);
$word = PosixWordString::fromString($longString);
expect($word->value)->toBe($longString);
expect(strlen($word->value))->toBe(700);
});
it('handles mixed case correctly', function () {
$mixed = PosixWordString::fromString('CamelCase_With_Underscore123');
expect($mixed->value)->toBe('CamelCase_With_Underscore123');
});
it('accepts strings starting with underscore', function () {
$leading = PosixWordString::fromString('_privateMethod');
expect($leading->value)->toBe('_privateMethod');
});
it('accepts strings starting with digit', function () {
// Note: Valid for word class, but not valid PHP identifiers
$digit = PosixWordString::fromString('123abc');
expect($digit->value)->toBe('123abc');
});
it('accepts strings with only digits', function () {
$digits = PosixWordString::fromString('123456');
expect($digits->value)->toBe('123456');
});
it('accepts strings with only underscores', function () {
$underscores = PosixWordString::fromString('___');
expect($underscores->value)->toBe('___');
});
});
describe('Boundary Cases', function () {
it('handles maximum practical length', function () {
// 255 characters - typical database VARCHAR limit
$longIdentifier = str_repeat('a', 255);
$word = PosixWordString::fromString($longIdentifier);
expect(strlen($word->value))->toBe(255);
});
it('handles alphanumeric only', function () {
$alnum = PosixWordString::fromString('abc123XYZ789');
expect($alnum->value)->toBe('abc123XYZ789');
});
it('handles underscores only', function () {
$underscores = PosixWordString::fromString('_____');
expect($underscores->value)->toBe('_____');
});
it('handles mixed content', function () {
$mixed = PosixWordString::fromString('a1_B2_c3_D4');
expect($mixed->value)->toBe('a1_B2_c3_D4');
});
});
});

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use App\Framework\String\ValueObjects\{
AlphanumericString,
NumericString,
HexadecimalString,
PrintableString
};
describe('AlphanumericString', function () {
it('creates from valid alphanumeric string', function () {
$str = AlphanumericString::fromString('abc123');
expect($str->value)->toBe('abc123');
});
it('rejects non-alphanumeric strings', function () {
AlphanumericString::fromString('abc-123');
})->throws(InvalidArgumentException::class, 'alphanumeric');
it('rejects empty strings', function () {
AlphanumericString::fromString('');
})->throws(InvalidArgumentException::class);
it('converts to TypedString', function () {
$str = AlphanumericString::fromString('test123');
$typed = $str->toTypedString();
expect($typed->value)->toBe('test123');
expect($typed->isAlphanumeric())->toBeTrue();
});
it('checks equality', function () {
$str1 = AlphanumericString::fromString('abc123');
$str2 = AlphanumericString::fromString('abc123');
$str3 = AlphanumericString::fromString('xyz789');
expect($str1->equals($str2))->toBeTrue();
expect($str1->equals($str3))->toBeFalse();
});
it('converts to string', function () {
$str = AlphanumericString::fromString('test123');
expect((string) $str)->toBe('test123');
});
});
describe('NumericString', function () {
it('creates from valid numeric string', function () {
$str = NumericString::fromString('12345');
expect($str->value)->toBe('12345');
});
it('rejects non-numeric strings', function () {
NumericString::fromString('12.34');
})->throws(InvalidArgumentException::class, 'digits');
it('rejects alphanumeric strings', function () {
NumericString::fromString('123abc');
})->throws(InvalidArgumentException::class);
it('rejects empty strings', function () {
NumericString::fromString('');
})->throws(InvalidArgumentException::class);
it('converts to integer', function () {
$str = NumericString::fromString('12345');
expect($str->toInt())->toBe(12345);
});
it('converts to float', function () {
$str = NumericString::fromString('12345');
expect($str->toFloat())->toBe(12345.0);
});
it('handles large numbers', function () {
$str = NumericString::fromString('999999999');
expect($str->toInt())->toBe(999999999);
});
});
describe('HexadecimalString', function () {
it('creates from valid hexadecimal string', function () {
$str = HexadecimalString::fromString('deadbeef');
expect($str->value)->toBe('deadbeef');
});
it('accepts uppercase hexadecimal', function () {
$str = HexadecimalString::fromString('DEADBEEF');
expect($str->value)->toBe('DEADBEEF');
});
it('accepts mixed case hexadecimal', function () {
$str = HexadecimalString::fromString('DeAdBeEf');
expect($str->value)->toBe('DeAdBeEf');
});
it('rejects non-hexadecimal strings', function () {
HexadecimalString::fromString('ghijk');
})->throws(InvalidArgumentException::class, 'hexadecimal');
it('rejects empty strings', function () {
HexadecimalString::fromString('');
})->throws(InvalidArgumentException::class);
it('converts to binary', function () {
$str = HexadecimalString::fromString('48656c6c6f'); // "Hello"
$binary = $str->toBinary();
expect($binary)->toBe('Hello');
});
it('converts to integer', function () {
$str = HexadecimalString::fromString('ff');
expect($str->toInt())->toBe(255);
});
it('handles long hexadecimal strings', function () {
$str = HexadecimalString::fromString('0123456789abcdef');
expect($str->toInt())->toBe(81985529216486895);
});
});
describe('PrintableString', function () {
it('creates from valid printable string', function () {
$str = PrintableString::fromString('Hello World!');
expect($str->value)->toBe('Hello World!');
});
it('accepts strings with whitespace', function () {
$str = PrintableString::fromString("Hello\nWorld\tTest");
expect($str->value)->toContain('Hello');
});
it('rejects strings with control characters', function () {
PrintableString::fromString("Hello\x00World");
})->throws(InvalidArgumentException::class, 'printable');
it('rejects empty strings', function () {
PrintableString::fromString('');
})->throws(InvalidArgumentException::class);
it('accepts special characters', function () {
$str = PrintableString::fromString('!@#$%^&*()_+-=[]{}|;:,.<>?');
expect($str->value)->toContain('!@#$');
});
it('converts to TypedString', function () {
$str = PrintableString::fromString('Test String');
$typed = $str->toTypedString();
expect($typed->value)->toBe('Test String');
expect($typed->isPrintable())->toBeTrue();
});
});

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use App\Framework\String\ValueObjects\TypedString;
describe('TypedString - Character Type Checks', function () {
it('validates alphanumeric strings', function () {
expect(TypedString::fromString('abc123')->isAlphanumeric())->toBeTrue();
expect(TypedString::fromString('ABC123')->isAlphanumeric())->toBeTrue();
expect(TypedString::fromString('abc-123')->isAlphanumeric())->toBeFalse();
expect(TypedString::fromString('')->isAlphanumeric())->toBeFalse();
});
it('validates alphabetic strings', function () {
expect(TypedString::fromString('abcABC')->isAlphabetic())->toBeTrue();
expect(TypedString::fromString('abc123')->isAlphabetic())->toBeFalse();
expect(TypedString::fromString('')->isAlphabetic())->toBeFalse();
});
it('validates digit strings', function () {
expect(TypedString::fromString('123456')->isDigits())->toBeTrue();
expect(TypedString::fromString('12.34')->isDigits())->toBeFalse();
expect(TypedString::fromString('abc')->isDigits())->toBeFalse();
expect(TypedString::fromString('')->isDigits())->toBeFalse();
});
it('validates lowercase strings', function () {
expect(TypedString::fromString('abc')->isLowercase())->toBeTrue();
expect(TypedString::fromString('Abc')->isLowercase())->toBeFalse();
expect(TypedString::fromString('')->isLowercase())->toBeFalse();
});
it('validates uppercase strings', function () {
expect(TypedString::fromString('ABC')->isUppercase())->toBeTrue();
expect(TypedString::fromString('Abc')->isUppercase())->toBeFalse();
expect(TypedString::fromString('')->isUppercase())->toBeFalse();
});
it('validates hexadecimal strings', function () {
expect(TypedString::fromString('deadbeef')->isHexadecimal())->toBeTrue();
expect(TypedString::fromString('DEADBEEF')->isHexadecimal())->toBeTrue();
expect(TypedString::fromString('0123456789abcdef')->isHexadecimal())->toBeTrue();
expect(TypedString::fromString('ghijk')->isHexadecimal())->toBeFalse();
expect(TypedString::fromString('')->isHexadecimal())->toBeFalse();
});
it('validates whitespace strings', function () {
expect(TypedString::fromString(' ')->isWhitespace())->toBeTrue();
expect(TypedString::fromString("\t\n")->isWhitespace())->toBeTrue();
expect(TypedString::fromString('abc')->isWhitespace())->toBeFalse();
expect(TypedString::fromString('')->isWhitespace())->toBeFalse();
});
it('validates printable strings', function () {
expect(TypedString::fromString('Hello World')->isPrintable())->toBeTrue();
expect(TypedString::fromString("Hello\x00World")->isPrintable())->toBeFalse();
expect(TypedString::fromString('')->isPrintable())->toBeFalse();
});
it('validates visible strings', function () {
expect(TypedString::fromString('abc123')->isVisible())->toBeTrue();
expect(TypedString::fromString('abc 123')->isVisible())->toBeFalse();
expect(TypedString::fromString('')->isVisible())->toBeFalse();
});
});
describe('TypedString - Length Checks', function () {
it('checks if string is empty', function () {
expect(TypedString::fromString('')->isEmpty())->toBeTrue();
expect(TypedString::fromString('abc')->isEmpty())->toBeFalse();
});
it('checks if string is not empty', function () {
expect(TypedString::fromString('abc')->isNotEmpty())->toBeTrue();
expect(TypedString::fromString('')->isNotEmpty())->toBeFalse();
});
it('returns string length', function () {
expect(TypedString::fromString('abc')->length())->toBe(3);
expect(TypedString::fromString('')->length())->toBe(0);
expect(TypedString::fromString('hello world')->length())->toBe(11);
});
it('checks minimum length', function () {
$str = TypedString::fromString('hello');
expect($str->hasMinLength(3))->toBeTrue();
expect($str->hasMinLength(5))->toBeTrue();
expect($str->hasMinLength(6))->toBeFalse();
});
it('checks maximum length', function () {
$str = TypedString::fromString('hello');
expect($str->hasMaxLength(10))->toBeTrue();
expect($str->hasMaxLength(5))->toBeTrue();
expect($str->hasMaxLength(4))->toBeFalse();
});
it('checks length range', function () {
$str = TypedString::fromString('hello');
expect($str->hasLengthBetween(3, 10))->toBeTrue();
expect($str->hasLengthBetween(5, 5))->toBeTrue();
expect($str->hasLengthBetween(6, 10))->toBeFalse();
expect($str->hasLengthBetween(1, 4))->toBeFalse();
});
it('checks exact length', function () {
$str = TypedString::fromString('hello');
expect($str->hasExactLength(5))->toBeTrue();
expect($str->hasExactLength(4))->toBeFalse();
expect($str->hasExactLength(6))->toBeFalse();
});
});
describe('TypedString - Pattern Matching', function () {
it('matches regular expressions', function () {
$str = TypedString::fromString('hello123');
expect($str->matches('/^hello/'))->toBeTrue();
expect($str->matches('/\d+$/'))->toBeTrue();
expect($str->matches('/^goodbye/'))->toBeFalse();
});
it('checks if starts with prefix', function () {
$str = TypedString::fromString('hello world');
expect($str->startsWith('hello'))->toBeTrue();
expect($str->startsWith('h'))->toBeTrue();
expect($str->startsWith('world'))->toBeFalse();
});
it('checks if ends with suffix', function () {
$str = TypedString::fromString('hello world');
expect($str->endsWith('world'))->toBeTrue();
expect($str->endsWith('d'))->toBeTrue();
expect($str->endsWith('hello'))->toBeFalse();
});
it('checks if contains substring', function () {
$str = TypedString::fromString('hello world');
expect($str->contains('hello'))->toBeTrue();
expect($str->contains('world'))->toBeTrue();
expect($str->contains('lo wo'))->toBeTrue();
expect($str->contains('goodbye'))->toBeFalse();
});
});
describe('TypedString - Comparison & Conversion', function () {
it('checks equality', function () {
$str1 = TypedString::fromString('hello');
$str2 = TypedString::fromString('hello');
$str3 = TypedString::fromString('world');
expect($str1->equals($str2))->toBeTrue();
expect($str1->equals($str3))->toBeFalse();
});
it('converts to string', function () {
$str = TypedString::fromString('hello');
expect($str->toString())->toBe('hello');
expect((string) $str)->toBe('hello');
});
});

View File

@@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
use App\Framework\String\ValueObjects\TypedString;
describe('TypedStringValidator - Fluent API', function () {
it('validates alphanumeric with fluent interface', function () {
$result = TypedString::fromString('abc123')
->validate()
->alphanumeric()
->orNull();
expect($result)->not->toBeNull();
expect($result->value)->toBe('abc123');
});
it('rejects non-alphanumeric strings', function () {
$result = TypedString::fromString('abc-123')
->validate()
->alphanumeric()
->orNull();
expect($result)->toBeNull();
});
it('validates length constraints', function () {
$result = TypedString::fromString('hello')
->validate()
->minLength(3)
->maxLength(10)
->orNull();
expect($result)->not->toBeNull();
});
it('rejects strings below minimum length', function () {
$result = TypedString::fromString('ab')
->validate()
->minLength(5)
->orNull();
expect($result)->toBeNull();
});
it('rejects strings above maximum length', function () {
$result = TypedString::fromString('hello world')
->validate()
->maxLength(5)
->orNull();
expect($result)->toBeNull();
});
});
describe('TypedStringValidator - Complex Chains', function () {
it('validates complex username format', function () {
$username = TypedString::fromString('user123')
->validate()
->alphanumeric()
->minLength(3)
->maxLength(20)
->orThrow('Invalid username');
expect($username->value)->toBe('user123');
});
it('validates password with custom rules', function () {
$password = TypedString::fromString('Pass123!')
->validate()
->minLength(8)
->custom(
fn($ts) => preg_match('/[A-Z]/', $ts->value) === 1,
'Must contain uppercase letter'
)
->custom(
fn($ts) => preg_match('/[0-9]/', $ts->value) === 1,
'Must contain digit'
)
->orThrow();
expect($password->value)->toBe('Pass123!');
});
it('throws on invalid password', function () {
TypedString::fromString('weak')
->validate()
->minLength(8)
->orThrow('Password too weak');
})->throws(InvalidArgumentException::class, 'Password too weak');
});
describe('TypedStringValidator - Error Handling', function () {
it('collects multiple validation errors', function () {
$validator = TypedString::fromString('ab')
->validate()
->alphanumeric()
->minLength(5);
expect($validator->fails())->toBeTrue();
$errors = $validator->getErrors();
expect($errors)->toHaveCount(1);
expect($errors[0])->toContain('at least 5 characters');
});
it('returns first error message', function () {
$validator = TypedString::fromString('ab')
->validate()
->minLength(5)
->maxLength(3);
$firstError = $validator->getFirstError();
expect($firstError)->toContain('at least 5 characters');
});
it('returns custom error messages', function () {
$validator = TypedString::fromString('abc-123')
->validate()
->alphanumeric('Username must be alphanumeric');
expect($validator->fails())->toBeTrue();
expect($validator->getFirstError())->toBe('Username must be alphanumeric');
});
});
describe('TypedStringValidator - Pattern Validators', function () {
it('validates regex patterns', function () {
$email = TypedString::fromString('test@example.com')
->validate()
->matches('/^[\w\.\-]+@[\w\.\-]+\.\w+$/', 'Invalid email format')
->orThrow();
expect($email->value)->toBe('test@example.com');
});
it('validates start with prefix', function () {
$result = TypedString::fromString('user_123')
->validate()
->startsWith('user_')
->orNull();
expect($result)->not->toBeNull();
});
it('validates end with suffix', function () {
$result = TypedString::fromString('file.txt')
->validate()
->endsWith('.txt')
->orNull();
expect($result)->not->toBeNull();
});
it('validates contains substring', function () {
$result = TypedString::fromString('hello_world')
->validate()
->contains('_')
->orNull();
expect($result)->not->toBeNull();
});
it('validates does not contain substring', function () {
$result = TypedString::fromString('helloworld')
->validate()
->doesNotContain(' ')
->orNull();
expect($result)->not->toBeNull();
});
});
describe('TypedStringValidator - Termination Methods', function () {
it('throws exception on validation failure', function () {
TypedString::fromString('invalid')
->validate()
->digits()
->orThrow('Must be numeric');
})->throws(InvalidArgumentException::class, 'Must be numeric');
it('returns null on validation failure', function () {
$result = TypedString::fromString('invalid')
->validate()
->digits()
->orNull();
expect($result)->toBeNull();
});
it('returns default value on validation failure', function () {
$result = TypedString::fromString('invalid')
->validate()
->digits()
->orDefault('123');
expect($result->value)->toBe('123');
});
it('passes validation and returns original value', function () {
$result = TypedString::fromString('valid123')
->validate()
->alphanumeric()
->orDefault('fallback');
expect($result->value)->toBe('valid123');
});
});
describe('TypedStringValidator - POSIX Validators', function () {
describe('posixAlnum', function () {
it('validates alphanumeric POSIX strings', function () {
$result = TypedString::fromString('abc123XYZ')
->validate()
->posixAlnum()
->orThrow();
expect($result->value)->toBe('abc123XYZ');
});
it('rejects strings with non-alphanumeric characters', function () {
TypedString::fromString('abc-123')
->validate()
->posixAlnum()
->orThrow();
})->throws(\InvalidArgumentException::class);
it('works in validation chain', function () {
$result = TypedString::fromString('test123')
->validate()
->notEmpty()
->posixAlnum()
->minLength(5)
->orThrow();
expect($result->value)->toBe('test123');
});
});
describe('posixAlpha', function () {
it('validates alphabetic POSIX strings', function () {
$result = TypedString::fromString('abcXYZ')
->validate()
->posixAlpha()
->orThrow();
expect($result->value)->toBe('abcXYZ');
});
it('rejects strings with digits', function () {
TypedString::fromString('abc123')
->validate()
->posixAlpha()
->orThrow();
})->throws(\InvalidArgumentException::class);
it('rejects strings with special characters', function () {
TypedString::fromString('hello-world')
->validate()
->posixAlpha()
->orThrow();
})->throws(\InvalidArgumentException::class);
});
describe('posixPunct', function () {
it('validates punctuation POSIX strings', function () {
$result = TypedString::fromString('!@#$%^&*()')
->validate()
->posixPunct()
->orThrow();
expect($result->value)->toBe('!@#$%^&*()');
});
it('rejects strings with alphanumeric characters', function () {
TypedString::fromString('abc!@#')
->validate()
->posixPunct()
->orThrow();
})->throws(\InvalidArgumentException::class);
});
describe('posixWord', function () {
it('validates word POSIX strings (alphanumeric + underscore)', function () {
$result = TypedString::fromString('user_name_123')
->validate()
->posixWord()
->orThrow();
expect($result->value)->toBe('user_name_123');
});
it('rejects strings with hyphens', function () {
TypedString::fromString('user-name')
->validate()
->posixWord()
->orThrow();
})->throws(\InvalidArgumentException::class);
it('validates PHP identifiers', function () {
$result = TypedString::fromString('_privateVar')
->validate()
->posixWord()
->orThrow();
expect($result->value)->toBe('_privateVar');
});
});
describe('posixLower', function () {
it('validates lowercase POSIX strings', function () {
$result = TypedString::fromString('hello')
->validate()
->posixLower()
->orThrow();
expect($result->value)->toBe('hello');
});
it('rejects strings with uppercase characters', function () {
TypedString::fromString('Hello')
->validate()
->posixLower()
->orThrow();
})->throws(\InvalidArgumentException::class);
it('rejects strings with digits', function () {
TypedString::fromString('hello123')
->validate()
->posixLower()
->orThrow();
})->throws(\InvalidArgumentException::class);
});
describe('posixUpper', function () {
it('validates uppercase POSIX strings', function () {
$result = TypedString::fromString('HELLO')
->validate()
->posixUpper()
->orThrow();
expect($result->value)->toBe('HELLO');
});
it('rejects strings with lowercase characters', function () {
TypedString::fromString('Hello')
->validate()
->posixUpper()
->orThrow();
})->throws(\InvalidArgumentException::class);
it('validates constant naming', function () {
$result = TypedString::fromString('MAX_VALUE')
->validate()
->posixUpper()
->orThrow();
expect($result->value)->toBe('MAX_VALUE');
});
});
describe('posixPrint', function () {
it('validates printable POSIX strings (includes space)', function () {
$result = TypedString::fromString('Hello World!')
->validate()
->posixPrint()
->orThrow();
expect($result->value)->toBe('Hello World!');
});
it('rejects strings with control characters', function () {
TypedString::fromString("Hello\x00World")
->validate()
->posixPrint()
->orThrow();
})->throws(\InvalidArgumentException::class);
});
describe('posixGraph', function () {
it('validates visible POSIX strings (excludes space)', function () {
$result = TypedString::fromString('Hello!')
->validate()
->posixGraph()
->orThrow();
expect($result->value)->toBe('Hello!');
});
it('rejects strings with spaces', function () {
TypedString::fromString('Hello World')
->validate()
->posixGraph()
->orThrow();
})->throws(\InvalidArgumentException::class);
it('validates URLs without spaces', function () {
$result = TypedString::fromString('https://example.com')
->validate()
->posixGraph()
->orThrow();
expect($result->value)->toBe('https://example.com');
});
});
describe('Complex POSIX Validation Chains', function () {
it('combines POSIX validators with length checks', function () {
$result = TypedString::fromString('user123')
->validate()
->posixAlnum()
->minLength(5)
->maxLength(20)
->orThrow();
expect($result->value)->toBe('user123');
});
it('combines POSIX with pattern matching', function () {
$result = TypedString::fromString('test_value')
->validate()
->posixWord()
->matches('/^[a-z_]+$/')
->orThrow();
expect($result->value)->toBe('test_value');
});
it('uses POSIX validators in complex validation scenarios', function () {
$result = TypedString::fromString('AdminUser')
->validate()
->notEmpty()
->posixAlpha()
->minLength(5)
->orDefault('Guest');
expect($result->value)->toBe('AdminUser');
});
});
});