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
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
417
tests/Framework/MagicLinks/MagicLinksValueObjectsTest.php
Normal file
417
tests/Framework/MagicLinks/MagicLinksValueObjectsTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
338
tests/Framework/Mfa/MfaServiceTest.php
Normal file
338
tests/Framework/Mfa/MfaServiceTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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;
|
||||
|
||||
253
tests/Integration/ExceptionAuditIntegrationTest.php
Normal file
253
tests/Integration/ExceptionAuditIntegrationTest.php
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
278
tests/Unit/Framework/String/ValueObjects/PosixStringTest.php
Normal file
278
tests/Unit/Framework/String/ValueObjects/PosixStringTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
296
tests/Unit/Framework/String/ValueObjects/PosixWordStringTest.php
Normal file
296
tests/Unit/Framework/String/ValueObjects/PosixWordStringTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
170
tests/Unit/Framework/String/ValueObjects/TypedStringTest.php
Normal file
170
tests/Unit/Framework/String/ValueObjects/TypedStringTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
42
tests/debug/test-auth-capture.php
Normal file
42
tests/debug/test-auth-capture.php
Normal 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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
123
tests/debug/test-reflection-performance.php
Normal file
123
tests/debug/test-reflection-performance.php
Normal 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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
912
tests/debug/test_token_classification.php
Normal file
912
tests/debug/test_token_classification.php
Normal 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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user