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

@@ -10,12 +10,12 @@ use App\Framework\ApiGateway\ValueObjects\ApiEndpoint;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\Headers;
use App\Framework\Http\Method as HttpMethod;
use App\Framework\Http\Url\Url;
use App\Framework\Http\Url\WhatwgUrl;
use App\Framework\HttpClient\AuthConfig;
use App\Framework\HttpClient\ClientRequest;
use App\Framework\HttpClient\ClientResponse;
use App\Framework\HttpClient\HttpClient;
use App\Framework\HttpClient\Status;
use App\Framework\Http\Status;
use App\Framework\Retry\RetryStrategy;
describe('ApiGateway', function () {
@@ -51,7 +51,7 @@ describe('ApiGateway', function () {
$mockCache = new class implements \App\Framework\Cache\Cache {
public function get(\App\Framework\Cache\CacheIdentifier ...$identifiers): \App\Framework\Cache\CacheResult
{
return new \App\Framework\Cache\CacheResult(hits: [], misses: $identifiers);
return \App\Framework\Cache\CacheResult::empty();
}
public function set(\App\Framework\Cache\CacheItem ...$items): bool { return true; }
public function has(\App\Framework\Cache\CacheIdentifier ...$identifiers): array { return []; }
@@ -89,39 +89,14 @@ describe('ApiGateway', function () {
$this->metrics = new \App\Framework\ApiGateway\Metrics\ApiMetrics();
$this->operationTracker = new class implements \App\Framework\Performance\OperationTracker {
public function startOperation(
string $operationId,
\App\Framework\Performance\PerformanceCategory $category,
array $contextData = []
): \App\Framework\Performance\PerformanceSnapshot {
return new \App\Framework\Performance\PerformanceSnapshot(
operationId: $operationId,
category: $category,
startTime: microtime(true),
duration: \App\Framework\Core\ValueObjects\Duration::fromMilliseconds(10),
memoryUsed: 1024,
peakMemory: 2048,
contextData: $contextData
);
}
$memoryMonitor = new \App\Framework\Performance\MemoryMonitor();
public function completeOperation(string $operationId): ?\App\Framework\Performance\PerformanceSnapshot {
return new \App\Framework\Performance\PerformanceSnapshot(
operationId: $operationId,
category: \App\Framework\Performance\PerformanceCategory::HTTP,
startTime: microtime(true) - 0.01,
duration: \App\Framework\Core\ValueObjects\Duration::fromMilliseconds(10),
memoryUsed: 1024,
peakMemory: 2048,
contextData: []
);
}
public function failOperation(string $operationId, \Throwable $exception): ?\App\Framework\Performance\PerformanceSnapshot {
return null;
}
};
$this->operationTracker = new \App\Framework\Performance\OperationTracker(
clock: $mockClock,
memoryMonitor: $memoryMonitor,
logger: null,
eventDispatcher: null
);
$this->apiGateway = new ApiGateway(
$this->httpClient,
@@ -136,7 +111,7 @@ describe('ApiGateway', function () {
$request = new class implements ApiRequest, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
@@ -174,14 +149,14 @@ describe('ApiGateway', function () {
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->options->auth)->not->toBeNull();
expect($this->capturedRequest->options->auth->type)->toBe('basic');
expect($this->capturedRequest->options->auth->type->value)->toBe('basic');
});
it('applies custom header authentication when ApiRequest uses AuthConfig::custom()', function () {
$request = new class implements ApiRequest, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
@@ -221,14 +196,14 @@ describe('ApiGateway', function () {
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->options->auth)->not->toBeNull();
expect($this->capturedRequest->options->auth->type)->toBe('custom');
expect($this->capturedRequest->options->auth->type->value)->toBe('custom');
});
it('does not apply authentication when ApiRequest does not implement HasAuth', function () {
$request = new class implements ApiRequest {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
@@ -270,7 +245,7 @@ describe('ApiGateway', function () {
$request = new class implements ApiRequest, HasPayload, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
@@ -329,7 +304,7 @@ describe('ApiGateway', function () {
$request = new class implements ApiRequest, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
@@ -376,7 +351,7 @@ describe('ApiGateway', function () {
$request = new class implements ApiRequest {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
@@ -414,7 +389,7 @@ describe('ApiGateway', function () {
$request = new class implements ApiRequest {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
@@ -445,14 +420,14 @@ describe('ApiGateway', function () {
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->method)->toBe('GET');
expect($this->capturedRequest->method->value)->toBe('GET');
});
it('supports POST requests with payload', function () {
$request = new class implements ApiRequest, HasPayload {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod
@@ -488,7 +463,7 @@ describe('ApiGateway', function () {
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->method)->toBe('POST');
expect($this->capturedRequest->method->value)->toBe('POST');
$bodyData = json_decode($this->capturedRequest->body, true);
expect($bodyData)->toBe(['data' => 'test']);
});
@@ -497,7 +472,7 @@ describe('ApiGateway', function () {
$request = new class implements ApiRequest, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test/123'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test/123'));
}
public function getMethod(): HttpMethod
@@ -533,14 +508,14 @@ describe('ApiGateway', function () {
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->method)->toBe('DELETE');
expect($this->capturedRequest->method->value)->toBe('DELETE');
});
it('supports PATCH requests with payload', function () {
$request = new class implements ApiRequest, HasPayload, HasAuth {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test/123'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test/123'));
}
public function getMethod(): HttpMethod
@@ -581,7 +556,7 @@ describe('ApiGateway', function () {
$response = $this->apiGateway->send($request);
expect($response->status)->toBe(Status::OK);
expect($this->capturedRequest->method)->toBe('PATCH');
expect($this->capturedRequest->method->value)->toBe('PATCH');
$bodyData = json_decode($this->capturedRequest->body, true);
expect($bodyData)->toBe(['name' => 'Updated']);
});
@@ -592,7 +567,7 @@ describe('ApiGateway', function () {
$request = new class implements ApiRequest {
public function getEndpoint(): ApiEndpoint
{
return ApiEndpoint::fromUrl(Url::parse('https://api.example.com/test'));
return ApiEndpoint::fromUrl(WhatwgUrl::parse('https://api.example.com/test'));
}
public function getMethod(): HttpMethod

View File

@@ -20,8 +20,8 @@ use App\Framework\DI\Container;
use App\Framework\DI\DefaultContainer;
use App\Framework\Discovery\InitializerProcessor;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\ReflectionProvider;
use App\Framework\Serializer\Php\PhpSerializer;
use App\Framework\Serializer\Php\PhpSerializerConfig;

View File

@@ -21,8 +21,8 @@ use App\Framework\DI\DefaultContainer;
use App\Framework\Discovery\Cache\DiscoveryCacheIdentifiers;
use App\Framework\Discovery\DiscoveryServiceBootstrapper;
use App\Framework\Discovery\InitializerProcessor;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\ReflectionProvider;
use App\Framework\Serializer\Php\PhpSerializer;
use App\Framework\Serializer\Php\PhpSerializerConfig;

View File

@@ -48,7 +48,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
@@ -99,7 +99,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
@@ -150,7 +150,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
@@ -210,7 +210,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
@@ -257,7 +257,7 @@ describe('Dynamic Routing Parameter Extraction', function () {
// UnifiedRouteVisitor benötigt ClassName und FilePath Value Objects
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$this->discoveryVisitor->visitClass($className, $filePath, $reflection);
@@ -301,7 +301,7 @@ describe('Dynamic Route Parameter Processing Debug', function () {
$discoveryVisitor->onScanStart();
$className = \App\Framework\Core\ValueObjects\ClassName::fromString(TestDynamicController::class);
$filePath = \App\Framework\Filesystem\FilePath::create('test-file.php');
$reflection = new \App\Framework\Reflection\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$reflection = new \App\Framework\ReflectionLegacy\WrappedReflectionClass(new \ReflectionClass(TestDynamicController::class));
$discoveryVisitor->visitClass($className, $filePath, $reflection);

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
use App\Framework\DI\ContainerCompiler;
use App\Framework\DI\DefaultContainer;
use App\Framework\DI\DependencyResolver;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
beforeEach(function () {
$this->tempDir = sys_get_temp_dir() . '/container-compiler-test-' . uniqid();

View File

@@ -10,8 +10,8 @@ use App\Framework\Core\PathProvider;
use App\Framework\DateTime\SystemClock;
use App\Framework\DI\DefaultContainer;
use App\Framework\Discovery\DiscoveryServiceBootstrapper;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\ReflectionProvider;
use PHPUnit\Framework\TestCase;
/**

View File

@@ -92,7 +92,7 @@ final class SimpleMemoryTest extends TestCase
$clock = new SystemClock();
// Create reflection provider without dependencies
$reflectionProvider = new \App\Framework\Reflection\CachedReflectionProvider();
$reflectionProvider = new \App\Framework\ReflectionLegacy\CachedReflectionProvider();
$config = new \App\Framework\Discovery\ValueObjects\DiscoveryConfiguration(
paths: ['/var/www/html/src'],

View File

@@ -12,7 +12,7 @@ use App\Framework\DateTime\SystemClock;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\UnifiedDiscoveryService;
use App\Framework\Discovery\ValueObjects\DiscoveryConfiguration;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Php\PhpSerializer;
use App\Framework\Serializer\Php\PhpSerializerConfig;

View File

@@ -6,7 +6,7 @@ namespace Tests\Framework\Http;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\MiddlewareDependencyResolver;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use PHPUnit\Framework\TestCase;
/**

View File

@@ -12,7 +12,7 @@ use App\Framework\Http\MiddlewareDependencyResolver;
use App\Framework\Http\Next;
use App\Framework\Http\RequestStateManager;
use App\Framework\Logging\Logger;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\ReflectionLegacy\ReflectionProvider;
use PHPUnit\Framework\TestCase;
final class MiddlewareDependencyResolverTest extends TestCase

View File

@@ -0,0 +1,417 @@
<?php
declare(strict_types=1);
use App\Framework\MagicLinks\ValueObjects\{
TokenAction,
MagicLinkToken,
TokenConfig,
ActionResult,
MagicLinkData
};
describe('TokenAction', function () {
it('creates valid token action', function () {
$action = new TokenAction('email_verification');
expect($action->value)->toBe('email_verification');
});
it('validates action format', function () {
new TokenAction('invalid-action'); // Hyphens not allowed
})->throws(InvalidArgumentException::class, 'must contain only lowercase letters and underscores');
it('rejects empty action', function () {
new TokenAction('');
})->throws(InvalidArgumentException::class, 'cannot be empty');
it('compares actions correctly', function () {
$action1 = new TokenAction('email_verification');
$action2 = new TokenAction('email_verification');
$action3 = new TokenAction('password_reset');
expect($action1->equals($action2))->toBeTrue();
expect($action1->equals($action3))->toBeFalse();
});
});
describe('MagicLinkToken', function () {
it('creates valid token', function () {
$token = new MagicLinkToken('abcdef0123456789'); // 16 chars
expect($token->value)->toBe('abcdef0123456789');
});
it('rejects short tokens', function () {
new MagicLinkToken('short'); // Less than 16 chars
})->throws(InvalidArgumentException::class, 'must be at least 16 characters');
it('generates cryptographically secure tokens', function () {
$token1 = MagicLinkToken::generate();
$token2 = MagicLinkToken::generate();
expect($token1->value)->not->toBe($token2->value);
expect(strlen($token1->value))->toBe(64); // 32 bytes * 2 (hex)
});
it('compares tokens with constant time', function () {
$token1 = new MagicLinkToken('abcdef0123456789');
$token2 = new MagicLinkToken('abcdef0123456789');
$token3 = new MagicLinkToken('different0123456');
expect($token1->equals($token2))->toBeTrue();
expect($token1->equals($token3))->toBeFalse();
});
it('generates tokens with custom length', function () {
$token = MagicLinkToken::generate(16); // 16 bytes
expect(strlen($token->value))->toBe(32); // 16 bytes * 2 (hex)
});
});
describe('TokenConfig', function () {
it('creates default config', function () {
$config = new TokenConfig();
expect($config->ttlSeconds)->toBe(3600);
expect($config->oneTimeUse)->toBeFalse();
expect($config->maxUses)->toBeNull();
expect($config->ipRestriction)->toBeFalse();
});
it('creates custom config', function () {
$config = new TokenConfig(
ttlSeconds: 7200,
oneTimeUse: true,
maxUses: null,
ipRestriction: true
);
expect($config->ttlSeconds)->toBe(7200);
expect($config->oneTimeUse)->toBeTrue();
expect($config->ipRestriction)->toBeTrue();
});
it('validates positive ttl', function () {
new TokenConfig(ttlSeconds: 0);
})->throws(InvalidArgumentException::class, 'TTL must be positive');
it('validates positive max uses', function () {
new TokenConfig(maxUses: 0);
})->throws(InvalidArgumentException::class, 'Max uses must be positive');
it('validates one-time-use consistency', function () {
new TokenConfig(oneTimeUse: true, maxUses: 5);
})->throws(InvalidArgumentException::class, 'One-time-use tokens cannot have maxUses');
it('creates email verification config', function () {
$config = TokenConfig::forEmailVerification();
expect($config->ttlSeconds)->toBe(86400); // 24 hours
expect($config->oneTimeUse)->toBeTrue();
});
it('creates password reset config', function () {
$config = TokenConfig::forPasswordReset();
expect($config->ttlSeconds)->toBe(3600); // 1 hour
expect($config->oneTimeUse)->toBeTrue();
expect($config->ipRestriction)->toBeTrue();
});
it('creates document access config', function () {
$config = TokenConfig::forDocumentAccess(3);
expect($config->ttlSeconds)->toBe(3600);
expect($config->maxUses)->toBe(3);
});
});
describe('ActionResult', function () {
it('creates successful result', function () {
$result = new ActionResult(
success: true,
message: 'Email verified',
data: ['user_id' => 123]
);
expect($result->isSuccess())->toBeTrue();
expect($result->message)->toBe('Email verified');
expect($result->data)->toBe(['user_id' => 123]);
});
it('creates failure result', function () {
$result = new ActionResult(
success: false,
message: 'Verification failed',
errors: ['Token expired']
);
expect($result->isSuccess())->toBeFalse();
expect($result->hasErrors())->toBeTrue();
expect($result->errors)->toBe(['Token expired']);
});
it('detects redirect presence', function () {
$result1 = new ActionResult(
success: true,
message: 'Success',
redirectUrl: '/dashboard'
);
$result2 = new ActionResult(
success: true,
message: 'Success'
);
expect($result1->hasRedirect())->toBeTrue();
expect($result2->hasRedirect())->toBeFalse();
});
it('creates success via factory method', function () {
$result = ActionResult::success(
message: 'Operation completed',
data: ['id' => 456],
redirectUrl: '/success'
);
expect($result->isSuccess())->toBeTrue();
expect($result->message)->toBe('Operation completed');
expect($result->data)->toBe(['id' => 456]);
expect($result->redirectUrl)->toBe('/success');
});
it('creates failure via factory method', function () {
$result = ActionResult::failure(
message: 'Operation failed',
errors: ['Invalid input', 'Permission denied']
);
expect($result->isSuccess())->toBeFalse();
expect($result->errors)->toHaveCount(2);
});
});
describe('MagicLinkData', function () {
it('creates valid magic link data', function () {
$now = new DateTimeImmutable();
$expiresAt = $now->modify('+1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('email_verification'),
payload: ['user_id' => 1, 'email' => 'test@example.com'],
expiresAt: $expiresAt,
createdAt: $now
);
expect($data->id)->toBe('test-123');
expect($data->action->value)->toBe('email_verification');
expect($data->payload)->toBe(['user_id' => 1, 'email' => 'test@example.com']);
});
it('detects expired tokens', function () {
$now = new DateTimeImmutable();
$past = $now->modify('-1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('test'),
payload: [],
expiresAt: $past,
createdAt: $now->modify('-2 hours')
);
expect($data->isExpired())->toBeTrue();
expect($data->isValid())->toBeFalse();
});
it('validates non-expired tokens', function () {
$now = new DateTimeImmutable();
$future = $now->modify('+1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('test'),
payload: [],
expiresAt: $future,
createdAt: $now
);
expect($data->isExpired())->toBeFalse();
expect($data->isValid())->toBeTrue();
});
it('invalidates one-time-use tokens after use', function () {
$now = new DateTimeImmutable();
$future = $now->modify('+1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('test'),
payload: [],
expiresAt: $future,
createdAt: $now,
oneTimeUse: true,
isUsed: true
);
expect($data->isValid())->toBeFalse();
});
it('tracks use count correctly', function () {
$now = new DateTimeImmutable();
$future = $now->modify('+1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('test'),
payload: [],
expiresAt: $future,
createdAt: $now,
useCount: 2,
maxUses: 5
);
expect($data->hasRemainingUses())->toBeTrue();
expect($data->isValid())->toBeTrue();
});
it('invalidates tokens exceeding max uses', function () {
$now = new DateTimeImmutable();
$future = $now->modify('+1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('test'),
payload: [],
expiresAt: $future,
createdAt: $now,
useCount: 5,
maxUses: 5
);
expect($data->hasRemainingUses())->toBeFalse();
expect($data->isValid())->toBeFalse();
});
it('calculates remaining time correctly', function () {
$now = new DateTimeImmutable();
$future = $now->modify('+3600 seconds');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('test'),
payload: [],
expiresAt: $future,
createdAt: $now
);
$remaining = $data->getSecondsUntilExpiry();
expect($remaining)->toBeGreaterThan(3590);
expect($remaining)->toBeLessThanOrEqual(3600);
});
it('returns zero for expired token remaining time', function () {
$now = new DateTimeImmutable();
$past = $now->modify('-1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('test'),
payload: [],
expiresAt: $past,
createdAt: $now->modify('-2 hours')
);
expect($data->getSecondsUntilExpiry())->toBe(0);
});
it('marks token as used immutably', function () {
$now = new DateTimeImmutable();
$future = $now->modify('+1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('test'),
payload: [],
expiresAt: $future,
createdAt: $now,
oneTimeUse: true
);
$usedData = $data->withUsed(new DateTimeImmutable());
expect($data->isUsed)->toBeFalse(); // Original unchanged
expect($usedData->isUsed)->toBeTrue();
expect($usedData->useCount)->toBe(1);
});
it('increments use count immutably', function () {
$now = new DateTimeImmutable();
$future = $now->modify('+1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('test'),
payload: [],
expiresAt: $future,
createdAt: $now,
useCount: 2
);
$incremented = $data->withIncrementedUseCount();
expect($data->useCount)->toBe(2); // Original unchanged
expect($incremented->useCount)->toBe(3);
});
it('serializes to array correctly', function () {
$now = new DateTimeImmutable();
$future = $now->modify('+1 hour');
$data = new MagicLinkData(
id: 'test-123',
action: new TokenAction('email_verification'),
payload: ['user_id' => 1],
expiresAt: $future,
createdAt: $now,
oneTimeUse: true,
createdByIp: '127.0.0.1',
userAgent: 'Mozilla/5.0'
);
$array = $data->toArray();
expect($array)->toHaveKey('id');
expect($array)->toHaveKey('action');
expect($array)->toHaveKey('payload');
expect($array)->toHaveKey('is_valid');
expect($array['action'])->toBe('email_verification');
expect($array['payload'])->toBe(['user_id' => 1]);
});
it('deserializes from array correctly', function () {
$array = [
'id' => 'test-123',
'action' => 'password_reset',
'payload' => ['user_id' => 456],
'expires_at' => '2025-12-31 23:59:59',
'created_at' => '2025-12-31 12:00:00',
'one_time_use' => true,
'created_by_ip' => '192.168.1.1',
'user_agent' => 'Test Agent',
'is_used' => false,
'use_count' => 0
];
$data = MagicLinkData::fromArray($array);
expect($data->id)->toBe('test-123');
expect($data->action->value)->toBe('password_reset');
expect($data->payload)->toBe(['user_id' => 456]);
expect($data->oneTimeUse)->toBeTrue();
expect($data->createdByIp)->toBe('192.168.1.1');
});
});

View File

@@ -0,0 +1,338 @@
<?php
declare(strict_types=1);
use App\Framework\Mfa\MfaService;
use App\Framework\Mfa\MfaSecretFactory;
use App\Framework\Mfa\Providers\TotpProvider;
use App\Framework\Mfa\ValueObjects\MfaChallenge;
use App\Framework\Mfa\ValueObjects\MfaCode;
use App\Framework\Mfa\ValueObjects\MfaMethod;
use App\Framework\Mfa\ValueObjects\MfaSecret;
use App\Framework\Random\SecureRandomGenerator;
beforeEach(function () {
$this->randomGenerator = new SecureRandomGenerator();
$this->secretFactory = new MfaSecretFactory($this->randomGenerator);
$this->totpProvider = new TotpProvider(timeStep: 30, digits: 6, windowSize: 1);
$this->mfaService = new MfaService($this->totpProvider);
});
describe('MfaSecretFactory', function () {
it('generates valid base32 secret', function () {
$secret = $this->secretFactory->generate();
expect($secret)->toBeInstanceOf(MfaSecret::class);
expect($secret->value)->toMatch('/^[A-Z2-7]+$/');
expect(strlen($secret->value))->toBeGreaterThanOrEqual(32);
});
it('generates secret with custom length', function () {
$secret = $this->secretFactory->generateWithLength(32);
// 32 bytes = 51-52 base32 characters (due to padding)
expect(strlen($secret->value))->toBeGreaterThanOrEqual(51);
});
it('throws exception for insufficient byte length', function () {
$this->secretFactory->generateWithLength(10);
})->throws(InvalidArgumentException::class, 'at least 16 bytes');
});
describe('MfaSecret', function () {
it('validates base32 format', function () {
$validSecret = 'JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP'; // Valid base32 (32+ chars)
$secret = MfaSecret::fromString($validSecret);
expect($secret->value)->toBe($validSecret);
});
it('throws exception for invalid base32 characters', function () {
MfaSecret::fromString('INVALID189'); // 1, 8, 9 not valid in base32
})->throws(InvalidArgumentException::class, 'base32 encoded');
it('throws exception for too short secret', function () {
MfaSecret::fromString('JBSWY3DP'); // Only 8 characters
})->throws(InvalidArgumentException::class, 'at least 32 characters');
it('generates QR code URI', function () {
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
$uri = $secret->toQrCodeUri('MyApp', 'user@example.com');
expect($uri)->toStartWith('otpauth://totp/');
expect($uri)->toContain('MyApp:user%40example.com'); // URL-encoded email
expect($uri)->toContain('secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
expect($uri)->toContain('issuer=MyApp');
});
it('masks secret for safe logging', function () {
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
$masked = $secret->getMasked();
expect($masked)->toBe('JBSW****3PXP');
expect($masked)->not->toContain('EHPK3PXPJBSWY3DPEHPK');
});
});
describe('MfaChallenge', function () {
it('creates valid challenge', function () {
$challenge = MfaChallenge::create(
challengeId: 'test-123',
method: MfaMethod::TOTP,
validitySeconds: 300,
maxAttempts: 3
);
expect($challenge->challengeId)->toBe('test-123');
expect($challenge->method)->toBe(MfaMethod::TOTP);
expect($challenge->attempts)->toBe(0);
expect($challenge->maxAttempts)->toBe(3);
expect($challenge->isValid())->toBeTrue();
});
it('detects expiration', function () {
$now = new DateTimeImmutable();
$created = $now->modify('-10 seconds');
$expired = $now->modify('-5 seconds'); // Expired 5 seconds ago
$challenge = new MfaChallenge(
challengeId: 'test-123',
method: MfaMethod::TOTP,
createdAt: $created,
expiresAt: $expired,
attempts: 0,
maxAttempts: 3
);
expect($challenge->isExpired())->toBeTrue();
expect($challenge->isValid())->toBeFalse();
});
it('tracks attempts correctly', function () {
$challenge = MfaChallenge::create('test-123', MfaMethod::TOTP, maxAttempts: 3);
expect($challenge->attempts)->toBe(0);
expect($challenge->hasAttemptsRemaining())->toBeTrue();
expect($challenge->getRemainingAttempts())->toBe(3);
$challenge = $challenge->withIncrementedAttempts();
expect($challenge->attempts)->toBe(1);
expect($challenge->getRemainingAttempts())->toBe(2);
$challenge = $challenge->withIncrementedAttempts();
$challenge = $challenge->withIncrementedAttempts();
expect($challenge->attempts)->toBe(3);
expect($challenge->hasAttemptsRemaining())->toBeFalse();
expect($challenge->isValid())->toBeFalse();
});
it('exports to array correctly', function () {
$challenge = MfaChallenge::create('test-123', MfaMethod::TOTP, validitySeconds: 300);
$array = $challenge->toArray();
expect($array)->toHaveKey('challenge_id');
expect($array)->toHaveKey('method');
expect($array)->toHaveKey('method_display');
expect($array)->toHaveKey('remaining_attempts');
expect($array)->toHaveKey('seconds_until_expiry');
expect($array)->toHaveKey('is_valid');
expect($array['challenge_id'])->toBe('test-123');
expect($array['method'])->toBe('totp');
expect($array['is_valid'])->toBeTrue();
});
});
describe('TotpProvider', function () {
it('generates challenge for TOTP', function () {
$challenge = $this->totpProvider->generateChallenge();
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
expect($challenge->method)->toBe(MfaMethod::TOTP);
expect($challenge->isValid())->toBeTrue();
});
it('verifies valid TOTP code', function () {
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
$challenge = $this->totpProvider->generateChallenge();
// Generate code for current time window
$currentTime = time();
$timeStep = (int) floor($currentTime / 30);
// We'll use the provider's internal logic to generate a valid code
// In real usage, the authenticator app would generate this
$reflector = new ReflectionClass($this->totpProvider);
$method = $reflector->getMethod('generateCodeForTimeStep');
$method->setAccessible(true);
$expectedCode = $method->invoke($this->totpProvider, $secret, $timeStep);
$code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT));
$result = $this->totpProvider->verify($challenge, $code, ['secret' => $secret]);
expect($result)->toBeTrue();
});
it('rejects invalid TOTP code', function () {
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
$challenge = $this->totpProvider->generateChallenge();
$wrongCode = MfaCode::fromString('000000');
$result = $this->totpProvider->verify($challenge, $wrongCode, ['secret' => $secret]);
expect($result)->toBeFalse();
});
it('accepts code within time window', function () {
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
$challenge = $this->totpProvider->generateChallenge();
$currentTime = time();
$previousTimeStep = (int) floor($currentTime / 30) - 1; // One window before
$reflector = new ReflectionClass($this->totpProvider);
$method = $reflector->getMethod('generateCodeForTimeStep');
$method->setAccessible(true);
$expectedCode = $method->invoke($this->totpProvider, $secret, $previousTimeStep);
$code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT));
// Should accept code from previous window (windowSize = 1)
$result = $this->totpProvider->verify($challenge, $code, ['secret' => $secret]);
expect($result)->toBeTrue();
});
it('throws exception when secret is missing', function () {
$challenge = $this->totpProvider->generateChallenge();
$code = MfaCode::fromString('123456');
$this->totpProvider->verify($challenge, $code, []);
})->throws(InvalidArgumentException::class, 'requires secret');
it('indicates TOTP does not require server-side code generation', function () {
expect($this->totpProvider->requiresCodeGeneration())->toBeFalse();
});
it('returns correct code validity period', function () {
expect($this->totpProvider->getCodeValiditySeconds())->toBe(30);
});
});
describe('MfaService', function () {
it('generates challenge via service', function () {
$challenge = $this->mfaService->generateChallenge(MfaMethod::TOTP);
expect($challenge)->toBeInstanceOf(MfaChallenge::class);
expect($challenge->method)->toBe(MfaMethod::TOTP);
});
it('verifies code via service', function () {
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
$challenge = $this->mfaService->generateChallenge(MfaMethod::TOTP);
$currentTime = time();
$timeStep = (int) floor($currentTime / 30);
$reflector = new ReflectionClass($this->totpProvider);
$method = $reflector->getMethod('generateCodeForTimeStep');
$method->setAccessible(true);
$expectedCode = $method->invoke($this->totpProvider, $secret, $timeStep);
$code = MfaCode::fromString(str_pad((string) $expectedCode, 6, '0', STR_PAD_LEFT));
$result = $this->mfaService->verify($challenge, $code, ['secret' => $secret]);
expect($result)->toBeTrue();
});
it('rejects verification for expired challenge', function () {
$now = new DateTimeImmutable();
$created = $now->modify('-10 seconds');
$expired = $now->modify('-5 seconds'); // Expired 5 seconds ago
$challenge = new MfaChallenge(
challengeId: 'test-123',
method: MfaMethod::TOTP,
createdAt: $created,
expiresAt: $expired,
attempts: 0,
maxAttempts: 3
);
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
$code = MfaCode::fromString('123456');
$result = $this->mfaService->verify($challenge, $code, ['secret' => $secret]);
expect($result)->toBeFalse();
});
it('checks if method is supported', function () {
expect($this->mfaService->supportsMethod(MfaMethod::TOTP))->toBeTrue();
expect($this->mfaService->supportsMethod(MfaMethod::SMS))->toBeFalse();
});
it('returns supported methods', function () {
$methods = $this->mfaService->getSupportedMethods();
expect($methods)->toHaveCount(1);
expect($methods[0])->toBe(MfaMethod::TOTP);
});
it('throws exception for unsupported method', function () {
$this->mfaService->generateChallenge(MfaMethod::SMS);
})->throws(App\Framework\Mfa\Exceptions\MfaException::class, 'No MFA provider registered');
});
describe('TOTP RFC 6238 Compliance', function () {
it('generates 6-digit codes by default', function () {
$provider = new TotpProvider();
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
$reflector = new ReflectionClass($provider);
$method = $reflector->getMethod('generateCodeForTimeStep');
$method->setAccessible(true);
$code = $method->invoke($provider, $secret, 12345);
$codeStr = str_pad((string) $code, 6, '0', STR_PAD_LEFT);
expect(strlen($codeStr))->toBe(6);
expect($codeStr)->toMatch('/^\d{6}$/');
});
it('supports 8-digit codes', function () {
$provider = new TotpProvider(digits: 8);
$secret = MfaSecret::fromString('JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP');
$reflector = new ReflectionClass($provider);
$method = $reflector->getMethod('generateCodeForTimeStep');
$method->setAccessible(true);
$code = $method->invoke($provider, $secret, 12345);
$codeStr = str_pad((string) $code, 8, '0', STR_PAD_LEFT);
expect(strlen($codeStr))->toBe(8);
});
it('uses 30-second time step by default', function () {
$provider = new TotpProvider();
expect($provider->getCodeValiditySeconds())->toBe(30);
});
it('validates constructor parameters', function () {
expect(fn() => new TotpProvider(timeStep: 0))
->toThrow(InvalidArgumentException::class, 'at least 1 second');
expect(fn() => new TotpProvider(digits: 5))
->toThrow(InvalidArgumentException::class, 'between 6 and 8');
expect(fn() => new TotpProvider(digits: 9))
->toThrow(InvalidArgumentException::class, 'between 6 and 8');
expect(fn() => new TotpProvider(windowSize: -1))
->toThrow(InvalidArgumentException::class, 'cannot be negative');
});
});

View File

@@ -6,8 +6,8 @@ use App\Framework\Console\ConsoleCommand;
use App\Framework\Core\ParameterTypeValidator;
use App\Framework\Examples\MultiPurposeAction;
use App\Framework\Mcp\McpTool;
use App\Framework\Reflection\WrappedReflectionMethod;
use App\Framework\Reflection\Cache\MethodCache;
use App\Framework\ReflectionLegacy\WrappedReflectionMethod;
use App\Framework\ReflectionLegacy\Cache\MethodCache;
use App\Framework\Core\ValueObjects\ClassName;
test('multi-purpose method has multiple attributes', function () {

View File

@@ -5,10 +5,10 @@ declare(strict_types=1);
namespace Tests\Framework\Reflection\Cache;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Reflection\Cache\AttributeCache;
use App\Framework\Reflection\Cache\ClassCache;
use App\Framework\Reflection\Cache\MethodCache;
use App\Framework\Reflection\Cache\ParameterCache;
use App\Framework\ReflectionLegacy\Cache\AttributeCache;
use App\Framework\ReflectionLegacy\Cache\ClassCache;
use App\Framework\ReflectionLegacy\Cache\MethodCache;
use App\Framework\ReflectionLegacy\Cache\ParameterCache;
use PHPUnit\Framework\TestCase;
use stdClass;

View File

@@ -6,12 +6,12 @@ namespace Tests\Framework\Reflection;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Filesystem\InMemoryStorage;
use App\Framework\Reflection\Cache\AttributeCache;
use App\Framework\Reflection\Cache\ClassCache;
use App\Framework\Reflection\Cache\MetadataCacheManager;
use App\Framework\Reflection\Cache\MethodCache;
use App\Framework\Reflection\Cache\ParameterCache;
use App\Framework\Reflection\ReflectionCache;
use App\Framework\ReflectionLegacy\Cache\AttributeCache;
use App\Framework\ReflectionLegacy\Cache\ClassCache;
use App\Framework\ReflectionLegacy\Cache\MetadataCacheManager;
use App\Framework\ReflectionLegacy\Cache\MethodCache;
use App\Framework\ReflectionLegacy\Cache\ParameterCache;
use App\Framework\ReflectionLegacy\ReflectionCache;
use PHPUnit\Framework\TestCase;
use stdClass;
use Tests\Framework\Reflection\Support\MemoryLeakDetector;

View File

@@ -6,12 +6,12 @@ namespace Tests\Framework\Reflection;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Filesystem\InMemoryStorage;
use App\Framework\Reflection\Cache\AttributeCache;
use App\Framework\Reflection\Cache\ClassCache;
use App\Framework\Reflection\Cache\MetadataCacheManager;
use App\Framework\Reflection\Cache\MethodCache;
use App\Framework\Reflection\Cache\ParameterCache;
use App\Framework\Reflection\ReflectionCache;
use App\Framework\ReflectionLegacy\Cache\AttributeCache;
use App\Framework\ReflectionLegacy\Cache\ClassCache;
use App\Framework\ReflectionLegacy\Cache\MetadataCacheManager;
use App\Framework\ReflectionLegacy\Cache\MethodCache;
use App\Framework\ReflectionLegacy\Cache\ParameterCache;
use App\Framework\ReflectionLegacy\ReflectionCache;
use PHPUnit\Framework\TestCase;
use stdClass;

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\ControllerRequestFactory;
use App\Framework\Logging\DefaultLogger;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\ReflectionLegacy\ReflectionProvider;
use App\Framework\Router\ParameterProcessor;
describe('ParameterProcessor Debug', function () {

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Validation\GroupAware;
use App\Framework\Validation\Rules\Email;
use App\Framework\Validation\Rules\Required;

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
use App\Framework\Audit\AuditLogger;
use App\Framework\Audit\InMemoryAuditLogger;
use App\Framework\Audit\ValueObjects\AuditEntry;
use App\Framework\DateTime\Clock;
use App\Framework\ExceptionHandling\Audit\ExceptionAuditLogger;
use App\Framework\ExceptionHandling\Context\ExceptionContextData;
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
use App\Framework\ExceptionHandling\ErrorKernel;
use App\Framework\ExceptionHandling\Factory\ExceptionFactory;
use App\Framework\ExceptionHandling\Reporter\Reporter;
use App\Framework\ExceptionHandling\Scope\ErrorScope;
use App\Framework\ExceptionHandling\Renderers\ErrorRendererFactory;
describe('Exception Audit Integration', 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::createFromFormat('U', (string) $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
);
$this->errorScope = new ErrorScope();
$this->factory = new ExceptionFactory($this->contextProvider, $this->errorScope);
});
it('automatically logs auditable exceptions through ErrorKernel', function () {
$reporter = new class implements Reporter {
public function report(\Throwable $exception): void
{
// Mock reporter
}
};
$rendererFactory = new ErrorRendererFactory(false);
$errorKernel = new ErrorKernel(
rendererFactory: $rendererFactory,
reporter: $reporter,
contextProvider: $this->contextProvider,
auditLogger: $this->exceptionAuditLogger
);
// Create auditable exception
$exception = $this->factory->createAuditable(
RuntimeException::class,
'User creation failed',
'user.create',
'UserService',
['user_id' => '123']
);
// Handle exception through ErrorKernel
$errorKernel->handle($exception);
// Check that audit entry was created
$entries = getAllAuditEntries($this->auditLogger);
expect($entries)->toHaveCount(1);
$entry = $entries[0];
expect($entry->success)->toBeFalse();
expect($entry->errorMessage)->toBe('User creation failed');
expect($entry->entityType)->toBe('userservice');
});
it('does not log non-auditable exceptions through ErrorKernel', function () {
$reporter = new class implements Reporter {
public function report(\Throwable $exception): void
{
// Mock reporter
}
};
$rendererFactory = new ErrorRendererFactory(false);
$errorKernel = new ErrorKernel(
rendererFactory: $rendererFactory,
reporter: $reporter,
contextProvider: $this->contextProvider,
auditLogger: $this->exceptionAuditLogger
);
// Create non-auditable exception
$exception = $this->factory->createNonAuditable(
RuntimeException::class,
'Validation error'
);
// Handle exception through ErrorKernel
$errorKernel->handle($exception);
// Check that no audit entry was created
$entries = getAllAuditEntries($this->auditLogger);
expect($entries)->toHaveCount(0);
});
it('logs exception with audit level through factory', function () {
$reporter = new class implements Reporter {
public function report(\Throwable $exception): void
{
// Mock reporter
}
};
$rendererFactory = new ErrorRendererFactory(false);
$errorKernel = new ErrorKernel(
rendererFactory: $rendererFactory,
reporter: $reporter,
contextProvider: $this->contextProvider,
auditLogger: $this->exceptionAuditLogger
);
// Create exception with audit level
$exception = $this->factory->withAuditLevel(
RuntimeException::class,
'Warning: Resource limit reached',
'WARNING'
);
// Handle exception
$errorKernel->handle($exception);
// Check that audit entry was created with level
$entries = getAllAuditEntries($this->auditLogger);
expect($entries)->toHaveCount(1);
$entry = $entries[0];
$context = $this->contextProvider->get($exception);
expect($context)->not->toBeNull();
expect($context->auditLevel)->toBe('WARNING');
});
it('preserves all context information in audit entry', function () {
$reporter = new class implements Reporter {
public function report(\Throwable $exception): void
{
// Mock reporter
}
};
$rendererFactory = new ErrorRendererFactory(false);
$errorKernel = new ErrorKernel(
rendererFactory: $rendererFactory,
reporter: $reporter,
contextProvider: $this->contextProvider,
auditLogger: $this->exceptionAuditLogger
);
// Create exception with full context (using Value Objects)
$context = ExceptionContextData::forOperation('payment.process', 'PaymentGateway')
->addData(['order_id' => 'order-123', 'amount' => 5000])
->withUserId('user-456')
->withRequestId('req-789') // String for backward compatibility (RequestId needs secret)
->withSessionId(\App\Framework\Http\Session\SessionId::fromString('session-abc'))
->withClientIp(\App\Framework\Http\IpAddress::from('192.168.1.100'))
->withUserAgent(\App\Framework\UserAgent\UserAgent::fromString('Mozilla/5.0'))
->withTags('payment', 'external_api', 'critical')
->withAuditable(true);
$exception = $this->factory->create(
RuntimeException::class,
'Payment processing failed',
$context
);
// Handle exception
$errorKernel->handle($exception);
// Check audit entry
$entries = getAllAuditEntries($this->auditLogger);
expect($entries)->toHaveCount(1);
$entry = $entries[0];
expect($entry->userId)->toBe('user-456');
expect($entry->entityId)->toBe('order-123');
expect($entry->ipAddress)->not->toBeNull();
expect((string) $entry->ipAddress)->toBe('192.168.1.100');
expect($entry->userAgent)->not->toBeNull();
expect($entry->userAgent->value)->toBe('Mozilla/5.0');
expect($entry->metadata)->toHaveKey('request_id');
expect($entry->metadata['request_id'])->toBe('req-789');
expect($entry->metadata)->toHaveKey('session_id');
expect($entry->metadata['session_id'])->toBe('session-abc');
expect($entry->metadata)->toHaveKey('tags');
expect($entry->metadata['tags'])->toContain('payment', 'external_api', 'critical');
});
});
/**
* 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

@@ -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');
});
});
});

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
echo "=== Debug VisitClass Method ===" . PHP_EOL;

View File

@@ -14,7 +14,7 @@ use App\Framework\DI\InitializerMapper;
use App\Framework\Discovery\UnifiedDiscoveryService;
use App\Framework\Discovery\ValueObjects\DiscoveryConfiguration;
use App\Framework\Filesystem\FileSystemService;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Json\JsonSerializer;
echo "=== Testing ALL Initializer Discovery ===\n\n";

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\HttpClient\AuthConfig;
use App\Framework\HttpClient\AuthType;
use App\Framework\HttpClient\ClientOptions;
// Test 1: Create AuthConfig directly
echo "Test 1: Direct AuthConfig creation\n";
$auth = AuthConfig::basic('testuser', 'testpass');
var_dump($auth);
echo "Auth type: " . ($auth->type->value ?? 'NULL') . "\n";
echo "Auth type is null: " . (is_null($auth->type) ? 'YES' : 'NO') . "\n\n";
// Test 2: Create ClientOptions with auth
echo "Test 2: ClientOptions with auth\n";
$options = new ClientOptions(
timeout: 10,
auth: $auth
);
var_dump($options->auth);
echo "Options auth type: " . ($options->auth->type->value ?? 'NULL') . "\n";
echo "Options auth type is null: " . (is_null($options->auth->type) ? 'YES' : 'NO') . "\n\n";
// Test 3: ClientOptions with() method
echo "Test 3: ClientOptions with() method\n";
$baseOptions = new ClientOptions(timeout: 10);
$newOptions = $baseOptions->with(['auth' => $auth]);
var_dump($newOptions->auth);
echo "New options auth type: " . ($newOptions->auth->type->value ?? 'NULL') . "\n";
echo "New options auth type is null: " . (is_null($newOptions->auth->type) ? 'YES' : 'NO') . "\n\n";
// Test 4: Check if readonly classes preserve properties
echo "Test 4: Readonly class property preservation\n";
$options1 = new ClientOptions(timeout: 10, auth: $auth);
$options2 = $options1; // Assignment
echo "Are they the same object? " . (($options1 === $options2) ? 'YES' : 'NO') . "\n";
echo "Options1 auth type: " . ($options1->auth->type->value ?? 'NULL') . "\n";
echo "Options2 auth type: " . ($options2->auth->type->value ?? 'NULL') . "\n";

View File

@@ -20,7 +20,7 @@ use App\Framework\DI\InitializerMapper;
use App\Framework\Discovery\UnifiedDiscoveryService;
use App\Framework\Discovery\ValueObjects\DiscoveryConfiguration;
use App\Framework\Filesystem\FileSystemService;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Json\JsonSerializer;
echo "=== Testing Context Filtering in Web Context ===\n\n";

View File

@@ -21,7 +21,7 @@ use App\Framework\Discovery\Results\InterfaceRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\LiveComponents\DataProviderResolver;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\ReflectionLegacy\WrappedReflectionClass;
echo "\nMinimal DataProvider Resolution Test\n";
echo str_repeat('=', 70) . "\n\n";

View File

@@ -13,7 +13,7 @@ use App\Framework\DI\InitializerMapper;
use App\Framework\Discovery\UnifiedDiscoveryService;
use App\Framework\Discovery\ValueObjects\DiscoveryConfiguration;
use App\Framework\Filesystem\FileSystemService;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Json\JsonSerializer;
use Tests\Debug\DebugInitializer;

View File

@@ -6,7 +6,7 @@ require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Discovery\Analysis\DependencyAnalyzer;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
echo "=== Testing Dependency Graph Analysis System ===\n\n";

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\DI\InitializerDependencyGraph;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
echo "=== Testing InitializerDependencyGraph API ===\n\n";

View File

@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\ErrorHandling\DummyTemplateRenderer;
use App\Framework\ErrorHandling\View\ErrorTemplateRenderer;
use App\Framework\Exception\ErrorHandlerContext;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\RequestContext;
use App\Framework\Exception\SystemContext;
use App\Framework\Http\RequestId;
use App\Framework\UserAgent\UserAgent;
// Erstelle eine Test-Exception
$testException = new \RuntimeException('Test error for enhanced debug page', 500);
// Erstelle ErrorHandlerContext mit Test-Daten
$exceptionContext = ExceptionContext::empty()
->withOperation('test_operation', 'TestComponent')
->withData([
'exception_message' => $testException->getMessage(),
'exception_file' => $testException->getFile(),
'exception_line' => $testException->getLine(),
'original_exception' => $testException,
]);
$requestContext = RequestContext::create(
clientIp: '127.0.0.1',
userAgent: UserAgent::fromString('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'),
requestMethod: 'GET',
requestUri: '/test-error',
hostIp: '127.0.0.1',
hostname: 'localhost',
protocol: 'HTTP/1.1',
port: '80',
requestId: new RequestId()
);
$systemContext = new SystemContext(
memoryUsage: '25 MB',
executionTime: 0.25, // 250ms
phpVersion: PHP_VERSION,
frameworkVersion: '1.0.0-dev',
environment: ['test_system_data' => 'value']
);
$metadata = [
'exception_class' => get_class($testException),
'error_level' => 'ERROR',
'http_status' => 500,
];
$errorHandlerContext = ErrorHandlerContext::create(
$exceptionContext,
$requestContext,
$systemContext,
$metadata
);
// Erstelle Template Renderer
$templateRenderer = new DummyTemplateRenderer();
// Erstelle Error Template Renderer
$errorRenderer = new ErrorTemplateRenderer($templateRenderer);
// Rendere die Enhanced Debug Page
try {
$html = $errorRenderer->renderFromHandlerContext($errorHandlerContext, true);
// Speichere in temporäre Datei zum Betrachten im Browser
$outputFile = __DIR__ . '/../tmp/enhanced-error-page-test.html';
file_put_contents($outputFile, $html);
echo "✅ Enhanced Error Page erfolgreich gerendert!\n";
echo "📁 Gespeichert in: {$outputFile}\n";
echo "🌐 Öffne die Datei im Browser, um das Ergebnis zu sehen.\n";
echo "\nHTML Länge: " . strlen($html) . " Zeichen\n";
} catch (\Throwable $e) {
echo "❌ Fehler beim Rendern der Enhanced Error Page:\n";
echo "Fehler: " . $e->getMessage() . "\n";
echo "Datei: " . $e->getFile() . "\n";
echo "Zeile: " . $e->getLine() . "\n";
echo "\nStack Trace:\n" . $e->getTraceAsString() . "\n";
}

View File

@@ -1,61 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use App\Framework\ErrorAggregation\ErrorEvent;
use App\Framework\DateTime\Clock;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\RequestContext;
use App\Framework\Exception\SystemContext;
use App\Framework\Exception\ErrorHandlerContext;
// Create test clock
$clock = 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(); }
};
echo "Creating exception and context...\n";
$exception = FrameworkException::create(
DatabaseErrorCode::QUERY_FAILED,
'Test database query failed'
);
$exceptionContext = ExceptionContext::empty()
->withOperation('test_operation', 'TestComponent')
->withData(['test_key' => 'test_value']);
$requestContext = RequestContext::fromGlobals();
$systemContext = SystemContext::current();
$errorHandlerContext = ErrorHandlerContext::create(
$exceptionContext,
$requestContext,
$systemContext,
['http_status' => 500]
);
echo "Creating ErrorEvent directly...\n";
try {
$errorEvent = ErrorEvent::fromErrorHandlerContext($errorHandlerContext, $clock);
echo "SUCCESS: ErrorEvent created\n";
echo " ID: " . $errorEvent->id->toString() . "\n";
echo " Service: " . $errorEvent->service . "\n";
echo " Component: " . $errorEvent->component . "\n";
echo " Operation: " . $errorEvent->operation . "\n";
echo " Error Message: " . $errorEvent->errorMessage . "\n";
} catch (\Throwable $e) {
echo "ERROR creating ErrorEvent: " . $e->getMessage() . "\n";
echo "Exception class: " . get_class($e) . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
}

View File

@@ -1,163 +0,0 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use App\Framework\ErrorAggregation\ErrorAggregator;
use App\Framework\ErrorAggregation\Storage\InMemoryErrorStorage;
use App\Framework\Queue\InMemoryQueue;
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\DateTime\Clock;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Exception\Core\DatabaseErrorCode;
use App\Framework\Exception\FrameworkException;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\RequestContext;
use App\Framework\Exception\SystemContext;
use App\Framework\Exception\ErrorHandlerContext;
// Create test cache
$cache = new class implements Cache {
private array $data = [];
public function get(CacheIdentifier ...$identifiers): CacheResult
{
$hits = [];
$misses = [];
foreach ($identifiers as $identifier) {
$key = $identifier->toString();
if (isset($this->data[$key])) {
$value = $this->data[$key];
$hits[$key] = $value instanceof CacheItem ? $value->value : $value;
} else {
$misses[] = $key;
}
}
return new CacheResult($hits, $misses);
}
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;
}
};
// Create test clock
$clock = 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(); }
};
echo "Creating ErrorAggregator...\n";
$storage = new InMemoryErrorStorage();
$alertQueue = new InMemoryQueue();
$errorAggregator = new ErrorAggregator(
storage: $storage,
cache: $cache,
clock: $clock,
alertQueue: $alertQueue
);
echo "Creating exception and context...\n";
$exception = FrameworkException::create(
DatabaseErrorCode::QUERY_FAILED,
'Test database query failed'
);
$exceptionContext = ExceptionContext::empty()
->withOperation('test_operation', 'TestComponent')
->withData(['test_key' => 'test_value']);
$requestContext = RequestContext::fromGlobals();
$systemContext = SystemContext::current();
$errorHandlerContext = ErrorHandlerContext::create(
$exceptionContext,
$requestContext,
$systemContext,
['http_status' => 500]
);
echo "Processing error...\n";
try {
$errorAggregator->processError($errorHandlerContext);
echo "SUCCESS: Error processed without exception\n";
} catch (\Throwable $e) {
echo "ERROR processing error: " . $e->getMessage() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
}
echo "\nGetting recent events...\n";
$recentEvents = $errorAggregator->getRecentEvents(10);
echo "Recent events count: " . count($recentEvents) . "\n";
echo "\nGetting active patterns...\n";
$activePatterns = $errorAggregator->getActivePatterns(10);
echo "Active patterns count: " . count($activePatterns) . "\n";
if (count($recentEvents) > 0) {
echo "\nFirst event details:\n";
$event = $recentEvents[0];
echo " Error message: " . $event->errorMessage . "\n";
echo " Component: " . $event->component . "\n";
echo " Operation: " . $event->operation . "\n";
}
if (count($activePatterns) > 0) {
echo "\nFirst pattern details:\n";
$pattern = $activePatterns[0];
echo " Component: " . $pattern->component . "\n";
echo " Occurrence count: " . $pattern->occurrenceCount . "\n";
}

View File

@@ -1,177 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\ErrorHandling\ErrorLogger;
use App\Framework\Exception\ErrorCode;
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;
echo "=== Testing Advanced Error Context System ===\n\n";
echo "1. Testing Basic FrameworkException with ErrorCode:\n";
try {
$exception = FrameworkException::create(
ErrorCode::DB_CONNECTION_FAILED,
"Connection to database failed"
)->withData([
'host' => 'localhost',
'port' => 3306,
'database' => 'test_db',
])->withOperation('database.connect', 'DatabaseManager');
throw $exception;
} catch (FrameworkException $e) {
echo " ✅ Exception created with ErrorCode\n";
echo " • Message: {$e->getMessage()}\n";
echo " • Error Code: {$e->getErrorCode()?->value}\n";
echo " • Category: {$e->getErrorCode()?->getCategory()}\n";
echo " • Recoverable: " . ($e->isRecoverable() ? 'Yes' : 'No') . "\n";
echo " • Retry After: " . ($e->getRetryAfter() ?? 'None') . " seconds\n";
echo " • Recovery Hint: {$e->getRecoveryHint()}\n";
echo " • Operation: {$e->getContext()->operation}\n";
echo " • Component: {$e->getContext()->component}\n\n";
}
echo "2. Testing ExceptionContext fluent interface:\n";
try {
$context = ExceptionContext::forOperation('user.create', 'UserService')
->withData([
'user_id' => 'user_123',
'email' => 'test@example.com',
])
->withDebug([
'validation_errors' => ['email_already_exists'],
'retry_count' => 1,
])
->withMetadata([
'security_event' => false,
'business_critical' => true,
]);
echo " ✅ ExceptionContext created with fluent interface\n";
echo " • Operation: {$context->operation}\n";
echo " • Component: {$context->component}\n";
echo " • Data keys: " . implode(', ', array_keys($context->data)) . "\n";
echo " • Debug keys: " . implode(', ', array_keys($context->debug)) . "\n";
echo " • Metadata keys: " . implode(', ', array_keys($context->metadata)) . "\n\n";
} catch (\Throwable $e) {
echo " ❌ Error: {$e->getMessage()}\n\n";
}
echo "3. Testing ErrorHandlerContext creation:\n";
try {
$exceptionContext = ExceptionContext::forOperation('payment.process', 'PaymentService')
->withData([
'amount' => 99.99,
'currency' => 'EUR',
'customer_id' => 'cust_123',
])
->withMetadata([
'security_event' => true,
'security_level' => 'WARN',
'security_description' => 'Suspicious payment attempt',
]);
$requestContext = RequestContext::fromGlobals();
$systemContext = SystemContext::current();
$errorContext = ErrorHandlerContext::create(
$exceptionContext,
$requestContext,
$systemContext,
['transaction_id' => 'tx_456']
);
echo " ✅ ErrorHandlerContext created\n";
echo " • Request method: " . ($errorContext->request->requestMethod ?? 'CLI') . "\n";
echo " • Client IP: " . ($errorContext->request->clientIp ?? 'localhost') . "\n";
echo " • Memory usage: {$errorContext->system->memoryUsage}\n";
echo " • PHP version: {$errorContext->system->phpVersion}\n";
echo " • Security event: " . ($errorContext->exception->metadata['security_event'] ? 'Yes' : 'No') . "\n\n";
// Test security event format
echo " 📊 Security Event Format:\n";
$securityEvent = $errorContext->toSecurityEventFormat('test-app');
foreach ($securityEvent as $key => $value) {
if (is_string($value) || is_numeric($value)) {
echo "{$key}: {$value}\n";
}
}
echo "\n";
} catch (\Throwable $e) {
echo " ❌ Error: {$e->getMessage()}\n\n";
}
echo "4. Testing Error Logging:\n";
try {
// Create an exception with security context
$exception = FrameworkException::create(
ErrorCode::SEC_UNAUTHORIZED_ACCESS,
"Unauthorized access attempt detected"
)->withData([
'endpoint' => '/admin/dashboard',
'user_agent' => 'curl/7.68.0',
'ip_address' => '192.168.1.100',
])->withMetadata([
'security_event' => true,
'security_level' => 'ERROR',
'security_description' => 'Unauthorized admin access attempt',
]);
$errorContext = ErrorHandlerContext::fromException($exception, [
'alert_sent' => true,
'blocked' => true,
]);
$errorLogger = new ErrorLogger();
echo " ✅ Created security exception and error context\n";
echo " • Exception type: " . get_class($exception) . "\n";
echo " • Error code: {$exception->getErrorCode()?->value}\n";
echo " • Security event: " . ($errorContext->exception->metadata['security_event'] ? 'Yes' : 'No') . "\n";
// Test logging data format
$loggingData = $errorContext->forLogging();
echo " • Logging data keys: " . implode(', ', array_keys($loggingData)) . "\n\n";
} catch (\Throwable $e) {
echo " ❌ Error: {$e->getMessage()}\n\n";
}
echo "5. Testing ErrorCode system:\n";
try {
$errorCodes = [
ErrorCode::DB_CONNECTION_FAILED,
ErrorCode::AUTH_TOKEN_EXPIRED,
ErrorCode::SEC_XSS_ATTEMPT,
ErrorCode::VAL_BUSINESS_RULE_VIOLATION,
ErrorCode::HTTP_RATE_LIMIT_EXCEEDED,
];
echo " 📋 ErrorCode Analysis:\n";
foreach ($errorCodes as $code) {
echo "{$code->value}:\n";
echo " - Category: {$code->getCategory()}\n";
echo " - Description: {$code->getDescription()}\n";
echo " - Recoverable: " . ($code->isRecoverable() ? 'Yes' : 'No') . "\n";
echo " - Retry after: " . ($code->getRetryAfterSeconds() ?? 'None') . " seconds\n";
echo " - Recovery hint: {$code->getRecoveryHint()}\n\n";
}
} catch (\Throwable $e) {
echo " ❌ Error: {$e->getMessage()}\n\n";
}
echo "=== Advanced Error Context System Test Completed ===\n";

View File

@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\Queue\Exceptions\JobNotFoundException;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\ErrorHandling\ErrorHandler;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\ResponseEmitter;
use App\Framework\Http\RequestIdGenerator;
echo "=== Testing ErrorHandler Enhancements ===\n\n";
// Create minimal container for ErrorHandler
$container = new DefaultContainer();
$emitter = new ResponseEmitter();
$requestIdGenerator = new RequestIdGenerator();
$errorHandler = new ErrorHandler(
$emitter,
$container,
$requestIdGenerator,
null,
true // Debug mode to see recovery hints
);
// Test 1: Create a JobNotFoundException (uses QUEUE007 error code)
echo "Test 1: JobNotFoundException with ErrorCode integration\n";
echo str_repeat('-', 60) . "\n";
try {
$jobId = JobId::fromString('test-job-123');
throw JobNotFoundException::byId($jobId);
} catch (JobNotFoundException $e) {
echo "Exception: " . get_class($e) . "\n";
echo "Message: " . $e->getMessage() . "\n";
echo "Error Code: " . $e->getErrorCode()->getValue() . "\n";
echo "Category: " . $e->getErrorCode()->getCategory() . "\n";
echo "Severity: " . $e->getErrorCode()->getSeverity()->value . "\n";
echo "Is Recoverable: " . ($e->getErrorCode()->isRecoverable() ? 'Yes' : 'No') . "\n";
echo "Recovery Hint: " . $e->getErrorCode()->getRecoveryHint() . "\n";
// Test ErrorHandler metadata creation
echo "\nTesting ErrorHandler metadata generation...\n";
$response = $errorHandler->createHttpResponse($e);
echo "HTTP Status Code would be: " . $response->status->value . "\n";
echo "Response created successfully!\n";
}
echo "\n" . str_repeat('=', 60) . "\n\n";
// Test 2: Test with different error code that has Retry-After
echo "Test 2: Testing with error code that has Retry-After\n";
echo str_repeat('-', 60) . "\n";
use App\Framework\Queue\Exceptions\ChainNotFoundException;
try {
throw ChainNotFoundException::byId('chain-123');
} catch (ChainNotFoundException $e) {
echo "Exception: " . get_class($e) . "\n";
echo "Error Code: " . $e->getErrorCode()->getValue() . "\n";
echo "Retry After: " . ($e->getErrorCode()->getRetryAfterSeconds() ?? 'null') . " seconds\n";
$response = $errorHandler->createHttpResponse($e);
echo "HTTP Status Code: " . $response->status->value . "\n";
echo "Response headers would include Retry-After if applicable\n";
}
echo "\n✅ All ErrorHandler enhancement tests completed!\n";

View File

@@ -1,194 +0,0 @@
<?php
declare(strict_types=1);
/**
* Test Script for ErrorHandling Module
*
* Tests the current error handling system with various exception types
* to verify handler registration, priority execution, and response generation.
*/
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\ErrorHandling\Handlers\{
ValidationErrorHandler,
DatabaseErrorHandler,
HttpErrorHandler,
FallbackErrorHandler
};
use App\Framework\ErrorHandling\{ErrorHandlerManager, ErrorHandlerRegistry};
use App\Framework\Validation\Exceptions\ValidationException;
use App\Framework\Validation\ValidationResult;
use App\Framework\Database\Exception\DatabaseException;
use App\Framework\Http\Exception\HttpException;
use App\Framework\Http\Status;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Logging\Logger;
use App\Framework\Logging\ValueObjects\LogContext;
use App\Framework\Logging\LogLevel;
echo "=== ErrorHandling Module Test ===\n\n";
// Setup Logger (mock for testing)
$logger = new class implements Logger {
public function debug(string $message, ?LogContext $context = null): void {
echo "📝 [Logger::debug] {$message}\n";
}
public function info(string $message, ?LogContext $context = null): void {
echo "📝 [Logger::info] {$message}\n";
}
public function notice(string $message, ?LogContext $context = null): void {
echo "📝 [Logger::notice] {$message}\n";
}
public function warning(string $message, ?LogContext $context = null): void {
echo "📝 [Logger::warning] {$message}\n";
}
public function error(string $message, ?LogContext $context = null): void {
echo "📝 [Logger::error] {$message}\n";
if ($context && $context->structured) {
echo " Context: " . print_r($context->structured, true) . "\n";
}
}
public function critical(string $message, ?LogContext $context = null): void {
echo "📝 [Logger::critical] {$message}\n";
}
public function alert(string $message, ?LogContext $context = null): void {
echo "📝 [Logger::alert] {$message}\n";
}
public function emergency(string $message, ?LogContext $context = null): void {
echo "📝 [Logger::emergency] {$message}\n";
}
public function log(LogLevel $level, string $message, ?LogContext $context = null): void {
echo "📝 [Logger::{$level->value}] {$message}\n";
}
};
// Setup ErrorHandlerManager
$registry = new ErrorHandlerRegistry();
$manager = new ErrorHandlerManager($registry);
// Register handlers in priority order
echo "Registering handlers...\n";
$manager->register(new ValidationErrorHandler());
$manager->register(new DatabaseErrorHandler($logger));
$manager->register(new HttpErrorHandler());
$manager->register(new FallbackErrorHandler($logger));
echo "✅ All handlers registered\n\n";
// Test 1: ValidationException
echo "--- Test 1: ValidationException ---\n";
try {
$validationResult = new 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);
$result = $manager->handleException($exception);
echo "✅ Handled: " . ($result->handled ? 'Yes' : 'No') . "\n";
echo " Message: {$result->message}\n";
echo " Status Code: {$result->statusCode}\n";
echo " Error Type: {$result->data['error_type']}\n";
echo " Errors: " . print_r($result->data['errors'], true) . "\n";
} catch (\Throwable $e) {
echo "❌ Error: {$e->getMessage()}\n";
}
echo "\n";
// Test 2: DatabaseException
echo "--- Test 2: DatabaseException ---\n";
try {
$exception = DatabaseException::fromContext(
'Connection failed: Too many connections',
ExceptionContext::empty()
);
$result = $manager->handleException($exception);
echo "✅ Handled: " . ($result->handled ? 'Yes' : 'No') . "\n";
echo " Message: {$result->message}\n";
echo " Status Code: {$result->statusCode}\n";
echo " Error Type: {$result->data['error_type']}\n";
echo " Retry After: {$result->data['retry_after']} seconds\n";
} catch (\Throwable $e) {
echo "❌ Error: {$e->getMessage()}\n";
}
echo "\n";
// Test 3: HttpException
echo "--- Test 3: HttpException (404 Not Found) ---\n";
try {
$exception = new HttpException(
'Resource not found',
Status::NOT_FOUND,
headers: ['X-Resource-Type' => 'User']
);
$result = $manager->handleException($exception);
echo "✅ Handled: " . ($result->handled ? 'Yes' : 'No') . "\n";
echo " Message: {$result->message}\n";
echo " Status Code: {$result->statusCode}\n";
echo " Error Type: {$result->data['error_type']}\n";
echo " Headers: " . print_r($result->data['headers'], true) . "\n";
} catch (\Throwable $e) {
echo "❌ Error: {$e->getMessage()}\n";
}
echo "\n";
// Test 4: Generic RuntimeException (Fallback)
echo "--- Test 4: Generic RuntimeException (Fallback Handler) ---\n";
try {
$exception = new \RuntimeException('Something unexpected happened');
$result = $manager->handleException($exception);
echo "✅ Handled: " . ($result->handled ? 'Yes' : 'No') . "\n";
echo " Message: {$result->message}\n";
echo " Status Code: {$result->statusCode}\n";
echo " Error Type: {$result->data['error_type']}\n";
echo " Exception Class: {$result->data['exception_class']}\n";
echo " Is Final: " . ($result->isFinal ? 'Yes' : 'No') . "\n";
} catch (\Throwable $e) {
echo "❌ Error: {$e->getMessage()}\n";
}
echo "\n";
// Test 5: PDOException (Database Handler)
echo "--- Test 5: PDOException ---\n";
try {
$exception = new \PDOException('SQLSTATE[HY000] [2002] Connection refused');
$result = $manager->handleException($exception);
echo "✅ Handled: " . ($result->handled ? 'Yes' : 'No') . "\n";
echo " Message: {$result->message}\n";
echo " Status Code: {$result->statusCode}\n";
echo " Error Type: {$result->data['error_type']}\n";
} catch (\Throwable $e) {
echo "❌ Error: {$e->getMessage()}\n";
}
echo "\n";
// Test 6: Handler Priority Order
echo "--- Test 6: Handler Priority Verification ---\n";
$handlers = $manager->getHandlers();
echo "Registered handlers in priority order:\n";
foreach ($handlers as $index => $handler) {
$priority = $handler->getPriority();
$name = $handler->getName();
echo " " . ($index + 1) . ". {$name} (Priority: {$priority->value})\n";
}
echo "\n";
echo "=== All Tests Completed ===\n";

View File

@@ -1,202 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
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\Storage\InMemoryErrorStorage;
use App\Framework\ErrorHandling\ErrorHandler;
use App\Framework\ErrorReporting\ErrorReporter;
use App\Framework\ErrorReporting\Storage\InMemoryErrorReportStorage;
use App\Framework\Exception\Core\DatabaseErrorCode;
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;
// Create all dependencies
$container = new DefaultContainer();
$emitter = new ResponseEmitter();
$requestIdGenerator = new RequestIdGenerator();
// Error Aggregation setup
$errorStorage = new InMemoryErrorStorage();
$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[$identifier->toString()];
} else {
$items[] = CacheItem::miss($identifier instanceof CacheKey ? $identifier : CacheKey::fromString($identifier->toString()));
}
}
return count($items) === 1
? CacheResult::single($items[0])
: CacheResult::multiple($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::forSetting($key, $value, $ttl) : CacheItem::miss($key);
$this->data[$keyStr] = $item;
return $item;
}
};
$clock = new SystemClock();
$alertQueue = new InMemoryQueue();
$errorAggregator = new ErrorAggregator(
storage: $errorStorage,
cache: $cache,
clock: $clock,
alertQueue: $alertQueue,
logger: null,
batchSize: 100,
maxRetentionDays: 90
);
// Error Reporting setup
$errorReportStorage = new InMemoryErrorReportStorage();
$reportQueue = new InMemoryQueue();
$errorReporter = new ErrorReporter(
storage: $errorReportStorage,
clock: $clock,
logger: null,
queue: $reportQueue,
asyncProcessing: false,
processors: [],
filters: []
);
// Create ErrorHandler
$errorHandler = new ErrorHandler(
emitter: $emitter,
container: $container,
requestIdGenerator: $requestIdGenerator,
errorAggregator: $errorAggregator,
errorReporter: $errorReporter,
logger: null,
isDebugMode: true,
securityHandler: null
);
echo "=== Testing Error Pipeline ===\n\n";
// Create test exception
$exception = FrameworkException::create(
DatabaseErrorCode::QUERY_FAILED,
'Test database error'
);
echo "1. Creating ErrorHandlerContext manually...\n";
$errorHandlerContext = (new \ReflectionClass($errorHandler))->getMethod('createErrorHandlerContext')->invoke($errorHandler, $exception, null);
echo " Context created successfully\n";
echo " ExceptionContext: operation={$errorHandlerContext->exception->operation}, component={$errorHandlerContext->exception->component}\n";
echo "\n2. Testing ErrorEvent::fromErrorHandlerContext...\n";
try {
$errorEvent = \App\Framework\ErrorAggregation\ErrorEvent::fromErrorHandlerContext($errorHandlerContext, $clock);
echo " ErrorEvent created successfully\n";
echo " Event ID: {$errorEvent->id}\n";
echo " Event message: {$errorEvent->errorMessage}\n";
} catch (\Throwable $e) {
echo " EXCEPTION in fromErrorHandlerContext: " . $e->getMessage() . "\n";
echo " AT: " . $e->getFile() . ":" . $e->getLine() . "\n";
echo " TRACE:\n";
foreach ($e->getTrace() as $frame) {
$file = $frame['file'] ?? 'unknown';
$line = $frame['line'] ?? 0;
$function = $frame['function'] ?? 'unknown';
echo " $file:$line - $function()\n";
}
exit(1);
}
echo "\n3. Testing ErrorAggregator.processError directly...\n";
try {
$errorAggregator->processError($errorHandlerContext);
echo " processError completed successfully\n";
} catch (\Throwable $e) {
echo " EXCEPTION in processError: " . $e->getMessage() . "\n";
echo " AT: " . $e->getFile() . ":" . $e->getLine() . "\n";
throw $e;
}
echo "\n3. Creating HTTP response from exception...\n";
$response = $errorHandler->createHttpResponse($exception);
echo "\n4. Checking ErrorAggregator storage...\n";
$events = $errorStorage->getRecentEvents(10);
echo " Events stored: " . count($events) . "\n";
if (count($events) > 0) {
echo " First event message: " . $events[0]->message . "\n";
echo " First event severity: " . $events[0]->severity->value . "\n";
} else {
echo " No events stored!\n";
}
echo "\n3. Checking ErrorReporter storage...\n";
$reports = $errorReportStorage->findRecent(10);
echo " Reports stored: " . count($reports) . "\n";
if (count($reports) > 0) {
echo " First report message: " . $reports[0]->message . "\n";
echo " First report exception: " . $reports[0]->exception . "\n";
echo " First report level: " . $reports[0]->level . "\n";
} else {
echo " No reports stored!\n";
}
echo "\n=== Test Complete ===\n";

View File

@@ -1,135 +0,0 @@
<?php
declare(strict_types=1);
/**
* Test script for ErrorReportingConfig integration
*
* Verifies that ErrorReportingConfig correctly loads from environment
* and applies environment-specific defaults.
*/
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\Config\Environment;
use App\Framework\Config\EnvironmentType;
use App\Framework\ErrorReporting\ErrorReportingConfig;
echo "=== Testing ErrorReportingConfig Integration ===\n\n";
// Test 1: Development Environment
echo "Test 1: Development Environment Configuration\n";
echo "=============================================\n";
$devEnv = new Environment(['APP_ENV' => 'development']);
$devConfig = ErrorReportingConfig::fromEnvironment($devEnv);
echo "✓ Config loaded from environment\n";
echo " - enabled: " . ($devConfig->enabled ? 'true' : 'false') . "\n";
echo " - asyncProcessing: " . ($devConfig->asyncProcessing ? 'true' : 'false') . " (expected: false for dev)\n";
echo " - filterLevels: " . (empty($devConfig->filterLevels) ? 'ALL' : implode(', ', $devConfig->filterLevels)) . "\n";
echo " - maxStackTraceDepth: {$devConfig->maxStackTraceDepth} (expected: 30)\n";
echo " - sanitizeSensitiveData: " . ($devConfig->sanitizeSensitiveData ? 'true' : 'false') . " (expected: false)\n";
echo " - maxReportsPerMinute: {$devConfig->maxReportsPerMinute} (expected: 1000)\n\n";
// Test 2: Production Environment
echo "Test 2: Production Environment Configuration\n";
echo "===========================================\n";
$prodEnv = new Environment(['APP_ENV' => 'production']);
$prodConfig = ErrorReportingConfig::fromEnvironment($prodEnv);
echo "✓ Config loaded from environment\n";
echo " - enabled: " . ($prodConfig->enabled ? 'true' : 'false') . "\n";
echo " - asyncProcessing: " . ($prodConfig->asyncProcessing ? 'true' : 'false') . " (expected: true)\n";
echo " - filterLevels: " . (empty($prodConfig->filterLevels) ? 'ALL' : implode(', ', $prodConfig->filterLevels)) . " (expected: error, critical, alert, emergency)\n";
echo " - maxStackTraceDepth: {$prodConfig->maxStackTraceDepth} (expected: 15)\n";
echo " - sanitizeSensitiveData: " . ($prodConfig->sanitizeSensitiveData ? 'true' : 'false') . " (expected: true)\n";
echo " - maxReportsPerMinute: {$prodConfig->maxReportsPerMinute} (expected: 30)\n\n";
// Test 3: Staging Environment
echo "Test 3: Staging Environment Configuration\n";
echo "========================================\n";
$stagingEnv = new Environment(['APP_ENV' => 'staging']);
$stagingConfig = ErrorReportingConfig::fromEnvironment($stagingEnv);
echo "✓ Config loaded from environment\n";
echo " - enabled: " . ($stagingConfig->enabled ? 'true' : 'false') . "\n";
echo " - asyncProcessing: " . ($stagingConfig->asyncProcessing ? 'true' : 'false') . " (expected: true)\n";
echo " - filterLevels: " . (empty($stagingConfig->filterLevels) ? 'ALL' : implode(', ', $stagingConfig->filterLevels)) . " (expected: warning and above)\n";
echo " - maxStackTraceDepth: {$stagingConfig->maxStackTraceDepth} (expected: 20)\n";
echo " - analyticsRetentionDays: {$stagingConfig->analyticsRetentionDays} (expected: 14)\n\n";
// Test 4: Environment Variable Overrides
echo "Test 4: Environment Variable Overrides\n";
echo "=====================================\n";
$overrideEnv = new Environment([
'APP_ENV' => 'production',
'ERROR_REPORTING_ENABLED' => 'false',
'ERROR_REPORTING_ASYNC' => 'false',
'ERROR_REPORTING_FILTER_LEVELS' => 'critical,emergency',
'ERROR_REPORTING_MAX_STACK_DEPTH' => '5',
'ERROR_REPORTING_SAMPLING_RATE' => '50'
]);
$overrideConfig = ErrorReportingConfig::fromEnvironment($overrideEnv);
echo "✓ Config with environment overrides\n";
echo " - enabled: " . ($overrideConfig->enabled ? 'true' : 'false') . " (override: false)\n";
echo " - asyncProcessing: " . ($overrideConfig->asyncProcessing ? 'true' : 'false') . " (override: false)\n";
echo " - filterLevels: " . implode(', ', $overrideConfig->filterLevels) . " (override: critical, emergency)\n";
echo " - maxStackTraceDepth: {$overrideConfig->maxStackTraceDepth} (override: 5)\n";
echo " - samplingRate: {$overrideConfig->samplingRate} (override: 50)\n\n";
// Test 5: Helper Methods
echo "Test 5: Helper Methods\n";
echo "====================\n";
$testConfig = ErrorReportingConfig::fromEnvironment($prodEnv);
// shouldReportLevel
$shouldReportError = $testConfig->shouldReportLevel('error');
$shouldReportDebug = $testConfig->shouldReportLevel('debug');
echo "✓ shouldReportLevel()\n";
echo " - 'error' level: " . ($shouldReportError ? 'REPORT' : 'SKIP') . " (expected: REPORT)\n";
echo " - 'debug' level: " . ($shouldReportDebug ? 'REPORT' : 'SKIP') . " (expected: SKIP in production)\n\n";
// shouldReportException
$normalException = new \RuntimeException('Test error');
$shouldReport = $testConfig->shouldReportException($normalException);
echo "✓ shouldReportException()\n";
echo " - RuntimeException: " . ($shouldReport ? 'REPORT' : 'SKIP') . " (expected: REPORT)\n\n";
// shouldSample
$samples = 0;
for ($i = 0; $i < 100; $i++) {
if ($testConfig->shouldSample()) {
$samples++;
}
}
echo "✓ shouldSample()\n";
echo " - Sampling rate: {$testConfig->samplingRate}%\n";
echo " - Samples in 100 attempts: {$samples} (expected: ~{$testConfig->samplingRate})\n\n";
// Test 6: Direct Environment Type
echo "Test 6: Direct Environment Type Configuration\n";
echo "============================================\n";
$directConfig = ErrorReportingConfig::forEnvironment(EnvironmentType::DEV, $devEnv);
echo "✓ Config created directly with EnvironmentType::DEV\n";
echo " - asyncProcessing: " . ($directConfig->asyncProcessing ? 'true' : 'false') . " (expected: false)\n";
echo " - maxReportsPerMinute: {$directConfig->maxReportsPerMinute} (expected: 1000)\n\n";
// Validation
echo "=== Validation Results ===\n";
echo "✓ All environment configurations loaded successfully\n";
echo "✓ Environment-specific defaults applied correctly\n";
echo "✓ Environment variable overrides work as expected\n";
echo "✓ Helper methods function correctly\n";
echo "✓ ErrorReportingConfig integration: PASSED\n";

View File

@@ -24,7 +24,7 @@ use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\Handlers\ConsoleHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Php\PhpSerializer;
echo "🎯 Testing Discovery Event System\n";
@@ -52,7 +52,7 @@ try {
// Register optional services in container
$container->singleton(\App\Framework\Logging\Logger::class, $logger);
$container->singleton(\App\Framework\Performance\MemoryMonitor::class, $memoryMonitor);
$container->singleton(\App\Framework\Reflection\ReflectionProvider::class, $reflectionProvider);
$container->singleton(\App\Framework\ReflectionLegacy\ReflectionProvider::class, $reflectionProvider);
$container->singleton(\App\Framework\Filesystem\FileSystemService::class, $fileSystemService);
$container->singleton(\App\Framework\Core\Events\EventDispatcher::class, $eventDispatcher);

View File

@@ -11,7 +11,7 @@ use App\Framework\Core\PathProvider;
use App\Framework\DateTime\SystemClock;
use App\Framework\DI\DefaultContainer;
use App\Framework\Discovery\DiscoveryServiceBootstrapper;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Json\JsonSerializer;
echo "=== Testing Initializer Execution ===\n\n";
@@ -37,7 +37,7 @@ try {
$container->instance(\App\Framework\Serializer\Serializer::class, $serializer);
$container->instance(\App\Framework\Cache\Cache::class, $cache);
$container->instance(\App\Framework\DateTime\Clock::class, $clock);
$container->instance(\App\Framework\Reflection\ReflectionProvider::class, new CachedReflectionProvider());
$container->instance(\App\Framework\ReflectionLegacy\ReflectionProvider::class, new CachedReflectionProvider());
echo "Current execution context: " . ExecutionContext::detect()->getType()->value . "\n\n";

View File

@@ -25,7 +25,7 @@ use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\Handlers\ConsoleHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Php\PhpSerializer;
echo "🧠 Testing Memory Management System for Discovery Module\n";
@@ -50,7 +50,7 @@ try {
// Register optional services in container
$container->singleton(\App\Framework\Logging\Logger::class, $logger);
$container->singleton(\App\Framework\Performance\MemoryMonitor::class, $memoryMonitor);
$container->singleton(\App\Framework\Reflection\ReflectionProvider::class, $reflectionProvider);
$container->singleton(\App\Framework\ReflectionLegacy\ReflectionProvider::class, $reflectionProvider);
$container->singleton(\App\Framework\Filesystem\FileSystemService::class, $fileSystemService);
echo "✅ Dependencies initialized\n";

View File

@@ -22,7 +22,7 @@ use App\Framework\Logging\DefaultLogger;
use App\Framework\Logging\Handlers\ConsoleHandler;
use App\Framework\Logging\LogLevel;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Php\PhpSerializer;
echo "🔍 Testing New DI System for Discovery Module\n";
@@ -47,7 +47,7 @@ try {
// Register optional services in container
$container->singleton(\App\Framework\Logging\Logger::class, $logger);
$container->singleton(\App\Framework\Performance\MemoryMonitor::class, $memoryMonitor);
$container->singleton(\App\Framework\Reflection\ReflectionProvider::class, $reflectionProvider);
$container->singleton(\App\Framework\ReflectionLegacy\ReflectionProvider::class, $reflectionProvider);
$container->singleton(\App\Framework\Filesystem\FileSystemService::class, $fileSystemService);
echo "✅ Dependencies initialized\n";

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/**
* Performance Test: Native PHP Reflection vs Cached Reflection
*
* This test measures the actual performance difference between
* native PHP reflection and the cached reflection provider.
*/
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use ReflectionClass;
use ReflectionMethod;
// Test class
class TestClass {
public function __construct(
private string $param1,
private int $param2,
private ?object $param3 = null
) {}
public function testMethod(string $arg1, int $arg2): void {}
}
$iterations = 1000;
$className = TestClass::class;
echo "=== Reflection Performance Test ===\n\n";
echo "Testing $iterations iterations for class: $className\n\n";
// Test 1: Native ReflectionClass
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$reflection = new ReflectionClass($className);
$methods = $reflection->getMethods();
$constructor = $reflection->getConstructor();
if ($constructor) {
$params = $constructor->getParameters();
}
}
$nativeTime = microtime(true) - $start;
$nativeTimeMs = ($nativeTime * 1000);
$nativeTimePerOp = ($nativeTimeMs / $iterations);
echo "1. Native ReflectionClass:\n";
echo " Total: " . round($nativeTimeMs, 2) . " ms\n";
echo " Per operation: " . round($nativeTimePerOp, 4) . " ms\n";
echo " Per operation: " . round($nativeTimePerOp * 1000, 2) . " microseconds\n\n";
// Test 2: Native ReflectionMethod (most common operation)
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$reflection = new ReflectionMethod($className, 'testMethod');
$params = $reflection->getParameters();
foreach ($params as $param) {
$type = $param->getType();
$name = $param->getName();
$hasDefault = $param->isDefaultValueAvailable();
}
}
$nativeMethodTime = microtime(true) - $start;
$nativeMethodTimeMs = ($nativeMethodTime * 1000);
$nativeMethodTimePerOp = ($nativeMethodTimeMs / $iterations);
echo "2. Native ReflectionMethod (getParameters):\n";
echo " Total: " . round($nativeMethodTimeMs, 2) . " ms\n";
echo " Per operation: " . round($nativeMethodTimePerOp, 4) . " ms\n";
echo " Per operation: " . round($nativeMethodTimePerOp * 1000, 2) . " microseconds\n\n";
// Test 3: Cached ReflectionProvider (first run - no cache)
$provider = new CachedReflectionProvider();
$classNameObj = ClassName::create($className);
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$class = $provider->getClass($classNameObj);
$methods = $provider->getMethods($classNameObj);
$params = $provider->getMethodParameters($classNameObj, '__construct');
}
$cachedFirstTime = microtime(true) - $start;
$cachedFirstTimeMs = ($cachedFirstTime * 1000);
$cachedFirstTimePerOp = ($cachedFirstTimeMs / $iterations);
echo "3. Cached ReflectionProvider (first run - no cache):\n";
echo " Total: " . round($cachedFirstTimeMs, 2) . " ms\n";
echo " Per operation: " . round($cachedFirstTimePerOp, 4) . " ms\n";
echo " Per operation: " . round($cachedFirstTimePerOp * 1000, 2) . " microseconds\n\n";
// Test 4: Cached ReflectionProvider (cached - second run)
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$class = $provider->getClass($classNameObj);
$methods = $provider->getMethods($classNameObj);
$params = $provider->getMethodParameters($classNameObj, '__construct');
}
$cachedSecondTime = microtime(true) - $start;
$cachedSecondTimeMs = ($cachedSecondTime * 1000);
$cachedSecondTimePerOp = ($cachedSecondTimeMs / $iterations);
echo "4. Cached ReflectionProvider (cached - second run):\n";
echo " Total: " . round($cachedSecondTimeMs, 2) . " ms\n";
echo " Per operation: " . round($cachedSecondTimePerOp, 4) . " ms\n";
echo " Per operation: " . round($cachedSecondTimePerOp * 1000, 2) . " microseconds\n\n";
// Comparison
echo "=== Comparison ===\n";
$speedup = $nativeTimePerOp / $cachedSecondTimePerOp;
echo "Speedup (cached vs native): " . round($speedup, 2) . "x\n";
echo "Overhead (cached first vs native): " . round(($cachedFirstTimePerOp / $nativeTimePerOp - 1) * 100, 1) . "%\n\n";
// Reality check: How many reflection calls per request?
echo "=== Reality Check ===\n";
echo "Typical request might call getMethodParameters() 10-50 times\n";
echo "Native: " . round($nativeMethodTimePerOp * 50, 2) . " ms for 50 calls\n";
echo "Cached: " . round($cachedSecondTimePerOp * 50, 2) . " ms for 50 calls\n";
echo "Difference: " . round(($nativeMethodTimePerOp - $cachedSecondTimePerOp) * 50, 2) . " ms\n";
echo "Is this significant? " . (($nativeMethodTimePerOp - $cachedSecondTimePerOp) * 50 > 1 ? "YES (>1ms)" : "NO (<1ms)") . "\n";

View File

@@ -11,7 +11,7 @@ use App\Framework\Filesystem\FileScanner;
use App\Framework\Mcp\Tools\SecurityAuditTools;
use App\Framework\Mcp\Tools\SecurityConfigurationTools;
use App\Framework\Mcp\Tools\SecurityMonitoringTools;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Router\CompiledRoutes;
echo "=== Testing Security Audit MCP Tools ===\n\n";

View File

@@ -10,7 +10,7 @@ use App\Framework\Core\PathProvider;
use App\Framework\DateTime\SystemClock;
use App\Framework\DI\DefaultContainer;
use App\Framework\Discovery\DiscoveryServiceBootstrapper;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Json\JsonSerializer;
echo "=== Testing Initializer-Created Services ===\n\n";
@@ -33,7 +33,7 @@ try {
$container->instance(\App\Framework\Serializer\Serializer::class, $serializer);
$container->instance(\App\Framework\Cache\Cache::class, $cache);
$container->instance(\App\Framework\DateTime\Clock::class, $clock);
$container->instance(\App\Framework\Reflection\ReflectionProvider::class, new CachedReflectionProvider());
$container->instance(\App\Framework\ReflectionLegacy\ReflectionProvider::class, new CachedReflectionProvider());
// Bootstrap the system (this runs all initializers)
$bootstrapper = new DiscoveryServiceBootstrapper($container, $clock);

View File

@@ -17,7 +17,7 @@ use App\Framework\Core\PathProvider;
use App\Framework\DateTime\SystemClock;
use App\Framework\DI\DefaultContainer;
use App\Framework\Discovery\DiscoveryServiceBootstrapper;
use App\Framework\Reflection\CachedReflectionProvider;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Json\JsonSerializer;
echo "=== Testing Web Context Initializer Execution ===\n\n";
@@ -43,7 +43,7 @@ try {
$container->instance(\App\Framework\Serializer\Serializer::class, $serializer);
$container->instance(\App\Framework\Cache\Cache::class, $cache);
$container->instance(\App\Framework\DateTime\Clock::class, $clock);
$container->instance(\App\Framework\Reflection\ReflectionProvider::class, new CachedReflectionProvider());
$container->instance(\App\Framework\ReflectionLegacy\ReflectionProvider::class, new CachedReflectionProvider());
// Create bootstrapper
$bootstrapper = new DiscoveryServiceBootstrapper($container, $clock);

View File

@@ -0,0 +1,912 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Framework\Tokenizer\PhpTokenizer;
use App\Framework\Tokenizer\ValueObjects\Token;
use App\Framework\Tokenizer\ValueObjects\TokenType;
/**
* Test script for token classification
* Tests various PHP structures and compares expected vs actual token types
*/
final class TokenClassificationTest
{
private PhpTokenizer $tokenizer;
public function __construct()
{
$this->tokenizer = new PhpTokenizer();
}
/**
* Run all test cases
*/
public function runAll(): void
{
$testCases = $this->getTestCases();
$total = count($testCases);
$passed = 0;
$failed = 0;
echo "=== Token Classification Test Suite ===\n\n";
echo "Running {$total} test cases...\n\n";
foreach ($testCases as $index => $testCase) {
echo str_repeat('=', 80) . "\n";
echo "Test Case " . ($index + 1) . ": {$testCase['name']}\n";
echo str_repeat('=', 80) . "\n\n";
$result = $this->runTestCase($testCase);
if ($result['passed']) {
$passed++;
echo "✅ PASSED\n\n";
} else {
$failed++;
echo "❌ FAILED\n\n";
}
}
echo str_repeat('=', 80) . "\n";
echo "Summary: {$passed}/{$total} passed, {$failed}/{$total} failed\n";
}
/**
* Run a single test case
*/
private function runTestCase(array $testCase): array
{
$code = $testCase['code'];
$expected = $testCase['expected'];
$expectedMetadata = $testCase['expectedMetadata'] ?? null;
echo "Code:\n";
$codeLines = explode("\n", $code);
foreach ($codeLines as $i => $line) {
echo sprintf(" %2d: %s\n", $i + 1, $line);
}
echo "\n";
// Tokenize the code
$tokens = $this->tokenizer->tokenize($code);
// Build actual token map
$actual = $this->buildTokenMap($tokens);
// Compare expected vs actual
$differences = $this->compareTokens($expected, $actual);
// Check metadata if expected
$metadataDifferences = [];
if ($expectedMetadata !== null) {
$metadataDifferences = $this->compareMetadata($tokens, $expectedMetadata);
}
// Display expected tokens
echo "Expected Tokens:\n";
foreach ($expected as $lineNum => $lineTokens) {
foreach ($lineTokens as $tokenValue => $expectedType) {
echo sprintf(" Line %d: '%s' → %s\n", $lineNum, $tokenValue, $expectedType);
}
}
echo "\n";
// Display actual tokens
echo "Actual Tokens:\n";
foreach ($actual as $lineNum => $lineTokens) {
foreach ($lineTokens as $tokenValue => $actualType) {
echo sprintf(" Line %d: '%s' → %s\n", $lineNum, $tokenValue, $actualType->value);
}
}
echo "\n";
// Display differences
if (empty($differences) && empty($metadataDifferences)) {
echo "Differences: None - All tokens and metadata match!\n";
return ['passed' => true, 'differences' => []];
}
echo "Differences:\n";
foreach ($differences as $diff) {
if ($diff['match']) {
echo sprintf(" ✅ Line %d: '%s' → %s (correct)\n",
$diff['line'],
$diff['token'],
$diff['expected']
);
} else {
echo sprintf(" ❌ Line %d: '%s' → Expected %s, got %s\n",
$diff['line'],
$diff['token'],
$diff['expected'],
$diff['actual']
);
}
}
// Display metadata differences
if (!empty($metadataDifferences)) {
echo "\nMetadata Differences:\n";
foreach ($metadataDifferences as $diff) {
if ($diff['match']) {
echo sprintf(" ✅ Line %d: '%s' → %s (correct)\n",
$diff['line'],
$diff['token'],
$diff['expected']
);
} else {
echo sprintf(" ❌ Line %d: '%s' → Expected %s, got %s\n",
$diff['line'],
$diff['token'],
$diff['expected'],
$diff['actual']
);
}
}
}
$allDifferences = array_merge($differences, $metadataDifferences);
return [
'passed' => empty(array_filter($allDifferences, fn($d) => !$d['match'])),
'differences' => $allDifferences
];
}
/**
* Build token map from TokenCollection
*/
private function buildTokenMap($tokens): array
{
$map = [];
foreach ($tokens as $token) {
$line = $token->line;
$value = trim($token->value);
// Skip whitespace-only tokens and PHP tags
if ($token->type === TokenType::WHITESPACE ||
$token->type === TokenType::PHP_TAG ||
empty($value)) {
continue;
}
if (!isset($map[$line])) {
$map[$line] = [];
}
// Use token value as key, token type as value
$map[$line][$value] = $token->type;
}
return $map;
}
/**
* Compare metadata for tokens
*/
private function compareMetadata($tokens, array $expectedMetadata): array
{
$differences = [];
// Build token lookup by line and value
$tokenLookup = [];
foreach ($tokens as $token) {
$line = $token->line;
$value = trim($token->value);
if (empty($value) || $token->type === TokenType::WHITESPACE || $token->type === TokenType::PHP_TAG) {
continue;
}
if (!isset($tokenLookup[$line])) {
$tokenLookup[$line] = [];
}
$tokenLookup[$line][$value] = $token;
}
// Compare expected metadata
foreach ($expectedMetadata as $lineNum => $lineMetadata) {
foreach ($lineMetadata as $tokenValue => $expectedMeta) {
$token = $tokenLookup[$lineNum][$tokenValue] ?? null;
if ($token === null) {
$differences[] = [
'line' => $lineNum,
'token' => $tokenValue,
'expected' => json_encode($expectedMeta),
'actual' => 'TOKEN_NOT_FOUND',
'match' => false
];
continue;
}
$actualMeta = $token->metadata;
// Check each expected metadata property
foreach ($expectedMeta as $property => $expectedValue) {
$actualValue = $actualMeta?->$property ?? null;
if ($property === 'isBuiltIn') {
$actualValue = $actualMeta?->isBuiltIn ?? false;
}
$match = $actualValue === $expectedValue;
if (!$match) {
$differences[] = [
'line' => $lineNum,
'token' => $tokenValue,
'expected' => "{$property}: " . ($expectedValue === true ? 'true' : ($expectedValue === false ? 'false' : $expectedValue)),
'actual' => "{$property}: " . ($actualValue === true ? 'true' : ($actualValue === false ? 'false' : ($actualValue ?? 'null'))),
'match' => false
];
}
}
}
}
return $differences;
}
/**
* Compare expected and actual tokens
*/
private function compareTokens(array $expected, array $actual): array
{
$differences = [];
$allLines = array_unique(array_merge(array_keys($expected), array_keys($actual)));
foreach ($allLines as $lineNum) {
$expectedTokens = $expected[$lineNum] ?? [];
$actualTokens = $actual[$lineNum] ?? [];
// Check all expected tokens
foreach ($expectedTokens as $tokenValue => $expectedTypeName) {
$expectedType = TokenType::tryFrom($expectedTypeName);
$actualType = $actualTokens[$tokenValue] ?? null;
$differences[] = [
'line' => $lineNum,
'token' => $tokenValue,
'expected' => $expectedTypeName,
'actual' => $actualType?->value ?? 'NOT_FOUND',
'match' => $actualType === $expectedType
];
}
// Check for unexpected tokens (optional - could be verbose)
foreach ($actualTokens as $tokenValue => $actualType) {
if (!isset($expectedTokens[$tokenValue])) {
// This token wasn't expected - could log as info
}
}
}
return $differences;
}
/**
* Get all test cases
*/
private function getTestCases(): array
{
return array_merge(
// Test Cases for Classes
$this->getClassTestCases(),
// Test Cases for Enums
$this->getEnumTestCases(),
// Test Cases for Attributes
$this->getAttributeTestCases(),
// Test Cases for Methods and Properties
$this->getMethodPropertyTestCases(),
// Test Cases for Metadata
$this->getMetadataTestCases()
);
}
/**
* Get test cases for classes
*/
private function getClassTestCases(): array
{
return [
[
'name' => 'Simple Class',
'code' => '<?php class MyClass {}',
'expected' => [
1 => [
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Final Class',
'code' => '<?php final class MyClass {}',
'expected' => [
1 => [
'final' => 'keyword_modifier',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Abstract Class',
'code' => '<?php abstract class MyClass {}',
'expected' => [
1 => [
'abstract' => 'keyword_modifier',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Readonly Class',
'code' => '<?php readonly class MyClass {}',
'expected' => [
1 => [
'readonly' => 'keyword_modifier',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Class with Extends',
'code' => '<?php class Child extends Parent {}',
'expected' => [
1 => [
'class' => 'keyword_other',
'Child' => 'class_name',
'extends' => 'keyword_other',
'Parent' => 'class_name',
],
],
],
[
'name' => 'Class with Implements',
'code' => '<?php class MyClass implements MyInterface {}',
'expected' => [
1 => [
'class' => 'keyword_other',
'MyClass' => 'class_name',
'implements' => 'keyword_other',
'MyInterface' => 'interface_name',
],
],
],
[
'name' => 'Class with Multiple Modifiers',
'code' => '<?php final readonly class MyClass {}',
'expected' => [
1 => [
'final' => 'keyword_modifier',
'readonly' => 'keyword_modifier',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Class with Extends and Implements',
'code' => '<?php class Child extends Parent implements MyInterface {}',
'expected' => [
1 => [
'class' => 'keyword_other',
'Child' => 'class_name',
'extends' => 'keyword_other',
'Parent' => 'class_name',
'implements' => 'keyword_other',
'MyInterface' => 'interface_name',
],
],
],
];
}
/**
* Get test cases for enums
*/
private function getEnumTestCases(): array
{
return [
[
'name' => 'Pure Enum',
'code' => '<?php enum Status {}',
'expected' => [
1 => [
'enum' => 'keyword_other',
'Status' => 'enum_name',
],
],
],
[
'name' => 'Backed Enum (String)',
'code' => '<?php enum Status: string {}',
'expected' => [
1 => [
'enum' => 'keyword_other',
'Status' => 'enum_name',
':' => 'operator',
'string' => 'keyword_type',
],
],
],
[
'name' => 'Backed Enum (Int)',
'code' => '<?php enum Status: int {}',
'expected' => [
1 => [
'enum' => 'keyword_other',
'Status' => 'enum_name',
':' => 'operator',
'int' => 'keyword_type',
],
],
],
[
'name' => 'Enum with Implements',
'code' => '<?php enum Status implements MyInterface {}',
'expected' => [
1 => [
'enum' => 'keyword_other',
'Status' => 'enum_name',
'implements' => 'keyword_other',
'MyInterface' => 'interface_name',
],
],
],
[
'name' => 'Final Enum',
'code' => '<?php final enum Status {}',
'expected' => [
1 => [
'final' => 'keyword_modifier',
'enum' => 'keyword_other',
'Status' => 'enum_name',
],
],
],
[
'name' => 'Backed Enum with Implements',
'code' => '<?php enum Status: string implements MyInterface {}',
'expected' => [
1 => [
'enum' => 'keyword_other',
'Status' => 'enum_name',
':' => 'operator',
'string' => 'keyword_type',
'implements' => 'keyword_other',
'MyInterface' => 'interface_name',
],
],
],
];
}
/**
* Get test cases for attributes
*/
private function getAttributeTestCases(): array
{
return [
[
'name' => 'Simple Attribute',
'code' => '<?php #[Route] class MyClass {}',
'expected' => [
1 => [
'#' => 'attribute',
'[' => 'bracket',
'Route' => 'attribute_name',
']' => 'bracket',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Attribute with Parameter',
'code' => '<?php #[Route(\'/api\')] class MyClass {}',
'expected' => [
1 => [
'#' => 'attribute',
'[' => 'bracket',
'Route' => 'attribute_name',
'(' => 'parenthesis',
'\'/api\'' => 'string_literal',
')' => 'parenthesis',
']' => 'bracket',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Attribute with Named Parameters',
'code' => '<?php #[Route(path: \'/api\', method: \'GET\')] class MyClass {}',
'expected' => [
1 => [
'#' => 'attribute',
'[' => 'bracket',
'Route' => 'attribute_name',
'(' => 'parenthesis',
'path' => 'attribute_name',
':' => 'operator',
'\'/api\'' => 'string_literal',
',' => 'punctuation',
'method' => 'attribute_name',
':' => 'operator',
'\'GET\'' => 'string_literal',
')' => 'parenthesis',
']' => 'bracket',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Multiple Attributes',
'code' => '<?php #[Route, Auth] class MyClass {}',
'expected' => [
1 => [
'#' => 'attribute',
'[' => 'bracket',
'Route' => 'attribute_name',
',' => 'punctuation',
'Auth' => 'attribute_name',
']' => 'bracket',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Attribute with Multiple Parameters',
'code' => '<?php #[Route(\'/api\', \'POST\')] class MyClass {}',
'expected' => [
1 => [
'#' => 'attribute',
'[' => 'bracket',
'Route' => 'attribute_name',
'(' => 'parenthesis',
'\'/api\'' => 'string_literal',
',' => 'punctuation',
'\'POST\'' => 'string_literal',
')' => 'parenthesis',
']' => 'bracket',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Attribute with Class Parameter',
'code' => '<?php #[Route(MyClass::class)] class MyClass {}',
'expected' => [
1 => [
'#' => 'attribute',
'[' => 'bracket',
'Route' => 'attribute_name',
'(' => 'parenthesis',
'MyClass' => 'class_name',
'::' => 'operator',
'class' => 'keyword_other',
')' => 'parenthesis',
']' => 'bracket',
'class' => 'keyword_other',
'MyClass' => 'class_name',
],
],
],
[
'name' => 'Attribute on Method',
'code' => '<?php class MyClass { #[Route] public function test() {} }',
'expected' => [
1 => [
'class' => 'keyword_other',
'MyClass' => 'class_name',
'{' => 'brace',
],
2 => [
'#' => 'attribute',
'[' => 'bracket',
'Route' => 'attribute_name',
']' => 'bracket',
'public' => 'keyword_modifier',
'function' => 'keyword_other',
'test' => 'method_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
],
];
}
/**
* Get test cases for methods and properties
*/
private function getMethodPropertyTestCases(): array
{
return [
[
'name' => 'Static Method Call',
'code' => '<?php MyClass::staticMethod();',
'expected' => [
1 => [
'MyClass' => 'class_name',
'::' => 'operator',
'staticMethod' => 'static_method_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
],
[
'name' => 'Instance Method Call',
'code' => '<?php $obj->instanceMethod();',
'expected' => [
1 => [
'$obj' => 'variable',
'->' => 'operator',
'instanceMethod' => 'instance_method_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
],
[
'name' => 'Property Access',
'code' => '<?php $obj->property;',
'expected' => [
1 => [
'$obj' => 'variable',
'->' => 'operator',
'property' => 'instance_property_name',
],
],
],
[
'name' => 'Static Property Access',
'code' => '<?php MyClass::$staticProperty;',
'expected' => [
1 => [
'MyClass' => 'class_name',
'::' => 'operator',
'$staticProperty' => 'variable',
],
],
],
[
'name' => 'Constructor Call',
'code' => '<?php new MyClass();',
'expected' => [
1 => [
'new' => 'keyword_other',
'MyClass' => 'constructor_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
],
[
'name' => 'Method Definition',
'code' => '<?php public function myMethod() {}',
'expected' => [
1 => [
'public' => 'keyword_modifier',
'function' => 'keyword_other',
'myMethod' => 'function_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
],
[
'name' => 'Method Definition in Class',
'code' => '<?php class MyClass { public function myMethod() {} }',
'expected' => [
1 => [
'class' => 'keyword_other',
'MyClass' => 'class_name',
'{' => 'brace',
],
2 => [
'public' => 'keyword_modifier',
'function' => 'keyword_other',
'myMethod' => 'method_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
],
[
'name' => 'Constructor Definition',
'code' => '<?php class MyClass { public function __construct() {} }',
'expected' => [
1 => [
'class' => 'keyword_other',
'MyClass' => 'class_name',
'{' => 'brace',
],
2 => [
'public' => 'keyword_modifier',
'function' => 'keyword_other',
'__construct' => 'constructor_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
],
[
'name' => 'Static Method Definition',
'code' => '<?php class MyClass { public static function staticMethod() {} }',
'expected' => [
1 => [
'class' => 'keyword_other',
'MyClass' => 'class_name',
'{' => 'brace',
],
2 => [
'public' => 'keyword_modifier',
'static' => 'keyword_modifier',
'function' => 'keyword_other',
'staticMethod' => 'method_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
],
[
'name' => 'Nullsafe Operator Method Call',
'code' => '<?php $obj?->method();',
'expected' => [
1 => [
'$obj' => 'variable',
'?->' => 'operator',
'method' => 'instance_method_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
],
];
}
/**
* Get test cases for metadata
*/
private function getMetadataTestCases(): array
{
return [
[
'name' => 'Built-in Function Call',
'code' => '<?php count($array);',
'expected' => [
1 => [
'count' => 'function_name',
'$array' => 'variable',
],
],
'expectedMetadata' => [
1 => [
'count' => [
'functionName' => 'count',
'isBuiltIn' => true,
],
],
],
],
[
'name' => 'Built-in Function strlen',
'code' => '<?php strlen($str);',
'expected' => [
1 => [
'strlen' => 'function_name',
'$str' => 'variable',
],
],
'expectedMetadata' => [
1 => [
'strlen' => [
'functionName' => 'strlen',
'isBuiltIn' => true,
],
],
],
],
[
'name' => 'Built-in Function array_map',
'code' => '<?php array_map($callback, $array);',
'expected' => [
1 => [
'array_map' => 'function_name',
'$callback' => 'variable',
'$array' => 'variable',
],
],
'expectedMetadata' => [
1 => [
'array_map' => [
'functionName' => 'array_map',
'isBuiltIn' => true,
],
],
],
],
[
'name' => 'User Function Call',
'code' => '<?php myFunction();',
'expected' => [
1 => [
'myFunction' => 'function_name',
'(' => 'parenthesis',
')' => 'parenthesis',
],
],
'expectedMetadata' => [
1 => [
'myFunction' => [
'functionName' => 'myFunction',
'isBuiltIn' => false,
],
],
],
],
[
'name' => 'Static Method Call with Metadata',
'code' => '<?php MyClass::staticMethod();',
'expected' => [
1 => [
'MyClass' => 'class_name',
'::' => 'operator',
'staticMethod' => 'static_method_name',
],
],
'expectedMetadata' => [
1 => [
'MyClass' => [
'className' => 'MyClass',
],
'staticMethod' => [
'functionName' => 'staticMethod',
'className' => 'MyClass',
],
],
],
],
[
'name' => 'Instance Method Call with Metadata',
'code' => '<?php $obj->instanceMethod();',
'expected' => [
1 => [
'$obj' => 'variable',
'->' => 'operator',
'instanceMethod' => 'instance_method_name',
],
],
'expectedMetadata' => [
1 => [
'instanceMethod' => [
'functionName' => 'instanceMethod',
],
],
],
],
];
}
}
// Run tests if executed directly
if (php_sapi_name() === 'cli') {
$test = new TokenClassificationTest();
$test->runAll();
}

View File

@@ -20,6 +20,17 @@ use RuntimeException;
*/
describe('ErrorKernel HTTP Response Generation', function () {
beforeEach(function () {
// Mock $_SERVER for API detection
$_SERVER['HTTP_ACCEPT'] = 'application/json';
$_SERVER['REQUEST_URI'] = '/api/test';
});
afterEach(function () {
// Cleanup
unset($_SERVER['HTTP_ACCEPT'], $_SERVER['REQUEST_URI']);
});
it('creates JSON API error response without context', function () {
$errorKernel = new ErrorKernel();
$exception = new RuntimeException('Test API error', 500);
@@ -27,7 +38,7 @@ describe('ErrorKernel HTTP Response Generation', function () {
$response = $errorKernel->createHttpResponse($exception, null, isDebugMode: false);
expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR);
expect($response->headers['Content-Type'])->toBe('application/json');
expect($response->headers->getFirst('Content-Type'))->toBe('application/json');
$body = json_decode($response->body, true);
expect($body['error']['message'])->toBe('An error occurred while processing your request.');
@@ -64,7 +75,7 @@ describe('ErrorKernel HTTP Response Generation', function () {
occurredAt: new \DateTimeImmutable(),
metadata: ['user_email' => 'test@example.com']
);
$contextProvider->set($exception, $contextData);
$contextProvider->attach($exception, $contextData);
$response = $errorKernel->createHttpResponse($exception, $contextProvider, isDebugMode: true);
@@ -94,7 +105,7 @@ describe('ResponseErrorRenderer', function () {
$response = $renderer->createResponse($exception, null);
expect($response->headers['Content-Type'])->toBe('application/json');
expect($response->headers->getFirst('Content-Type'))->toBe('application/json');
});
it('creates HTML response for non-API requests', function () {
@@ -107,7 +118,7 @@ describe('ResponseErrorRenderer', function () {
$response = $renderer->createResponse($exception, null);
expect($response->headers['Content-Type'])->toBe('text/html; charset=utf-8');
expect($response->headers->getFirst('Content-Type'))->toBe('text/html; charset=utf-8');
expect($response->body)->toContain('<!DOCTYPE html>');
expect($response->body)->toContain('An error occurred while processing your request.');
});
@@ -126,7 +137,7 @@ describe('ResponseErrorRenderer', function () {
requestId: 'req-67890',
occurredAt: new \DateTimeImmutable()
);
$contextProvider->set($exception, $contextData);
$contextProvider->attach($exception, $contextData);
$response = $renderer->createResponse($exception, $contextProvider);
@@ -168,7 +179,7 @@ describe('ExceptionContextProvider WeakMap functionality', function () {
occurredAt: new \DateTimeImmutable()
);
$contextProvider->set($exception, $contextData);
$contextProvider->attach($exception, $contextData);
$retrieved = $contextProvider->get($exception);
expect($retrieved)->not->toBeNull();
@@ -197,7 +208,7 @@ describe('ExceptionContextProvider WeakMap functionality', function () {
occurredAt: new \DateTimeImmutable()
);
$contextProvider->set($exception, $contextData);
$contextProvider->attach($exception, $contextData);
// Verify context exists
expect($contextProvider->get($exception))->not->toBeNull();
@@ -223,16 +234,16 @@ describe('Context enrichment with boundary metadata', function () {
requestId: 'req-abc',
occurredAt: new \DateTimeImmutable()
);
$contextProvider->set($exception, $initialContext);
$contextProvider->attach($exception, $initialContext);
// ErrorBoundary enriches with boundary metadata
$existingContext = $contextProvider->get($exception);
$enrichedContext = $existingContext->withMetadata([
$enrichedContext = $existingContext->addMetadata([
'error_boundary' => 'user_boundary',
'boundary_failure' => true,
'fallback_executed' => true
]);
$contextProvider->set($exception, $enrichedContext);
$contextProvider->attach($exception, $enrichedContext);
// Retrieve and verify enriched context
$finalContext = $contextProvider->get($exception);
@@ -251,17 +262,17 @@ describe('Context enrichment with boundary metadata', function () {
requestId: 'req-http-123',
occurredAt: new \DateTimeImmutable()
);
$contextProvider->set($exception, $initialContext);
$contextProvider->attach($exception, $initialContext);
// Enrich with HTTP-specific fields
$existingContext = $contextProvider->get($exception);
$enrichedContext = $existingContext->withMetadata([
$enrichedContext = $existingContext->addMetadata([
'client_ip' => '192.168.1.100',
'user_agent' => 'Mozilla/5.0',
'http_method' => 'POST',
'request_uri' => '/api/users'
]);
$contextProvider->set($exception, $enrichedContext);
$contextProvider->attach($exception, $enrichedContext);
// Verify both original and enriched data
$finalContext = $contextProvider->get($exception);
@@ -273,6 +284,17 @@ describe('Context enrichment with boundary metadata', function () {
});
describe('End-to-end integration scenario', function () {
beforeEach(function () {
// Mock $_SERVER for API detection
$_SERVER['HTTP_ACCEPT'] = 'application/json';
$_SERVER['REQUEST_URI'] = '/api/test';
});
afterEach(function () {
// Cleanup
unset($_SERVER['HTTP_ACCEPT'], $_SERVER['REQUEST_URI']);
});
it('demonstrates full exception handling flow with context enrichment', function () {
// Setup
$errorKernel = new ErrorKernel();
@@ -289,24 +311,24 @@ describe('End-to-end integration scenario', function () {
occurredAt: new \DateTimeImmutable(),
metadata: ['user_email' => 'test@example.com']
);
$contextProvider->set($exception, $serviceContext);
$contextProvider->attach($exception, $serviceContext);
// 3. ErrorBoundary catches and enriches with boundary metadata
$boundaryContext = $contextProvider->get($exception)->withMetadata([
$boundaryContext = $contextProvider->get($exception)->addMetadata([
'error_boundary' => 'user_registration_boundary',
'boundary_failure' => true,
'fallback_executed' => false
]);
$contextProvider->set($exception, $boundaryContext);
$contextProvider->attach($exception, $boundaryContext);
// 4. HTTP layer enriches with request metadata
$httpContext = $contextProvider->get($exception)->withMetadata([
$httpContext = $contextProvider->get($exception)->addMetadata([
'client_ip' => '203.0.113.42',
'user_agent' => 'Mozilla/5.0 (Windows NT 10.0)',
'http_method' => 'POST',
'request_uri' => '/api/users/register'
]);
$contextProvider->set($exception, $httpContext);
$contextProvider->attach($exception, $httpContext);
// 5. ErrorKernel generates HTTP response
$response = $errorKernel->createHttpResponse($exception, $contextProvider, isDebugMode: true);
@@ -323,6 +345,6 @@ describe('End-to-end integration scenario', function () {
// Metadata can be accessed programmatically via $contextProvider->get($exception)->metadata
expect($response->status)->toBe(Status::INTERNAL_SERVER_ERROR);
expect($response->headers['Content-Type'])->toBe('application/json');
expect($response->headers->getFirst('Content-Type'))->toBe('application/json');
});
});