fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled

This commit is contained in:
2025-11-24 21:28:25 +01:00
parent 4eb7134853
commit 77abc65cd7
1327 changed files with 91915 additions and 9909 deletions

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\DI;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\Exceptions\InitializerCycleException;
use App\Framework\DI\InitializerDependencyAnalyzer;
use App\Framework\DI\InitializerDependencyGraph;
use App\Framework\Reflection\ReflectionService;
use App\Framework\Reflection\SimpleReflectionService;
describe('InitializerDependencyGraph', function () {
it('can add initializers and build execution order', function () {
$reflectionService = new SimpleReflectionService();
$graph = new InitializerDependencyGraph($reflectionService);
$graph->addInitializer(
'ServiceA',
ClassName::create('App\Test\ServiceAInitializer'),
MethodName::create('__invoke')
);
$graph->addInitializer(
'ServiceB',
ClassName::create('App\Test\ServiceBInitializer'),
MethodName::create('__invoke')
);
expect($graph->hasNode('ServiceA'))->toBeTrue();
expect($graph->hasNode('ServiceB'))->toBeTrue();
expect($graph->hasNode('ServiceC'))->toBeFalse();
});
it('uses explicit dependencies when provided', function () {
$reflectionService = new SimpleReflectionService();
$graph = new InitializerDependencyGraph($reflectionService);
$graph->addInitializer(
'ServiceA',
ClassName::create('App\Test\ServiceAInitializer'),
MethodName::create('__invoke'),
['ServiceB', 'ServiceC'], // Explicit dependencies
100 // Priority
);
$node = $graph->getNode('ServiceA');
expect($node)->not->toBeNull();
expect($node->dependencies)->toBe(['ServiceB', 'ServiceC']);
});
it('detects circular dependencies', function () {
$reflectionService = new SimpleReflectionService();
$graph = new InitializerDependencyGraph($reflectionService);
// Create a cycle: A -> B -> A
$graph->addInitializer(
'ServiceA',
ClassName::create('App\Test\ServiceAInitializer'),
MethodName::create('__invoke'),
['ServiceB'] // A depends on B
);
$graph->addInitializer(
'ServiceB',
ClassName::create('App\Test\ServiceBInitializer'),
MethodName::create('__invoke'),
['ServiceA'] // B depends on A (cycle!)
);
expect(fn() => $graph->getExecutionOrder())->toThrow(InitializerCycleException::class);
});
it('provides cycle information with dependency paths', function () {
$reflectionService = new SimpleReflectionService();
$graph = new InitializerDependencyGraph($reflectionService);
// Create a cycle: A -> B -> A
$graph->addInitializer(
'ServiceA',
ClassName::create('App\Test\ServiceAInitializer'),
MethodName::create('__invoke'),
['ServiceB']
);
$graph->addInitializer(
'ServiceB',
ClassName::create('App\Test\ServiceBInitializer'),
MethodName::create('__invoke'),
['ServiceA']
);
try {
$graph->getExecutionOrder();
expect(false)->toBeTrue(); // Should not reach here
} catch (InitializerCycleException $e) {
$cycles = $e->getCycles();
expect($cycles)->not->toBeEmpty();
$paths = $e->getDependencyPaths();
expect($paths)->not->toBeEmpty();
}
});
it('calculates correct execution order for linear dependencies', function () {
$reflectionService = new SimpleReflectionService();
$graph = new InitializerDependencyGraph($reflectionService);
// C depends on B, B depends on A
$graph->addInitializer(
'ServiceA',
ClassName::create('App\Test\ServiceAInitializer'),
MethodName::create('__invoke'),
[] // No dependencies
);
$graph->addInitializer(
'ServiceB',
ClassName::create('App\Test\ServiceBInitializer'),
MethodName::create('__invoke'),
['ServiceA'] // B depends on A
);
$graph->addInitializer(
'ServiceC',
ClassName::create('App\Test\ServiceCInitializer'),
MethodName::create('__invoke'),
['ServiceB'] // C depends on B
);
$order = $graph->getExecutionOrder();
// A should come before B, B should come before C
$indexA = array_search('ServiceA', $order, true);
$indexB = array_search('ServiceB', $order, true);
$indexC = array_search('ServiceC', $order, true);
expect($indexA)->toBeLessThan($indexB);
expect($indexB)->toBeLessThan($indexC);
});
it('integrates with InitializerDependencyAnalyzer when provided', function () {
$reflectionService = new SimpleReflectionService();
$analyzer = new InitializerDependencyAnalyzer(null);
$graph = new InitializerDependencyGraph($reflectionService, $analyzer);
// Graph should accept analyzer without errors
expect($graph)->toBeInstanceOf(InitializerDependencyGraph::class);
});
});

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
use App\Framework\Attributes\Route;
use App\Framework\Cache\Cache;
use App\Framework\Cache\Driver\InMemoryCache;
use App\Framework\Cache\GeneralCache;
use App\Framework\Core\PathProvider;
use App\Framework\DateTime\SystemClock;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\Storage\DiscoveryCacheManager;
use App\Framework\Discovery\UnifiedDiscoveryService;
use App\Framework\Discovery\ValueObjects\DiscoveryConfiguration;
use App\Framework\Discovery\ValueObjects\DiscoveryContext;
use App\Framework\Discovery\ValueObjects\DiscoveryOptions;
use App\Framework\Discovery\ValueObjects\ScanType;
use App\Framework\Filesystem\FileSystemService;
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
use App\Framework\Serializer\Php\PhpSerializer;
use App\Framework\Serializer\Php\PhpSerializerConfig;
beforeEach(function () {
// Setup cache and services similar to DiscoveryCacheIntegrationTest
$cacheDriver = new InMemoryCache();
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
$this->cache = new GeneralCache($cacheDriver, $serializer);
$this->clock = new SystemClock();
$this->fileSystemService = new FileSystemService();
$this->reflectionProvider = new CachedReflectionProvider();
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : dirname(__DIR__, 5);
$this->pathProvider = new PathProvider($basePath);
$this->discoveryContext = new DiscoveryContext(
paths: [$this->pathProvider->getSourcePath()->toString()],
scanType: ScanType::FULL,
options: DiscoveryOptions::default(),
startTime: $this->clock->now(),
executionContext: null
);
$this->cacheManager = new DiscoveryCacheManager(
cache: $this->cache,
clock: $this->clock,
fileSystemService: $this->fileSystemService,
logger: null,
ttlHours: 24
);
// Perform discovery to create cache
$configuration = new DiscoveryConfiguration(
paths: [$this->pathProvider->getSourcePath()],
useCache: true,
enableMemoryMonitoring: false,
memoryLimitMB: 128,
maxFilesPerBatch: 100
);
$discoveryService = new UnifiedDiscoveryService(
pathProvider: $this->pathProvider,
cache: $this->cache,
clock: $this->clock,
reflectionProvider: $this->reflectionProvider,
configuration: $configuration
);
$this->discoveredRegistry = $discoveryService->discover();
// Store in cache
$this->cacheManager->store($this->discoveryContext, $this->discoveredRegistry);
});
test('discovery cache can be loaded', function () {
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
expect($cachedRegistry)->not->toBeNull()
->and($cachedRegistry)->toBeInstanceOf(DiscoveryRegistry::class);
})->skip('Requires existing cache');
test('cached registry has content', function () {
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
if ($cachedRegistry === null) {
$this->markTestSkipped('No cached registry found - run discovery first');
}
expect($cachedRegistry->hasContent())->toBeTrue()
->and($cachedRegistry->isEmpty())->toBeFalse();
});
test('cached registry contains routes', function () {
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
if ($cachedRegistry === null) {
$this->markTestSkipped('No cached registry found - run discovery first');
}
$routeClass = Route::class;
$routeAttributes = $cachedRegistry->attributes->get($routeClass);
expect($routeAttributes)->toBeArray()
->and(count($routeAttributes))->toBeGreaterThan(0);
});
test('route attributes are correctly deserialized', function () {
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
if ($cachedRegistry === null) {
$this->markTestSkipped('No cached registry found - run discovery first');
}
$routeClass = Route::class;
$routeAttributes = $cachedRegistry->attributes->get($routeClass);
if (empty($routeAttributes)) {
$this->markTestSkipped('No routes found in cache');
}
$firstRoute = $routeAttributes[0];
expect($firstRoute)->toBeInstanceOf(\App\Framework\Discovery\ValueObjects\DiscoveredAttribute::class)
->and($firstRoute->className)->toBeInstanceOf(\App\Framework\Core\ValueObjects\ClassName::class)
->and($firstRoute->attributeClass)->toBe($routeClass);
});
test('route attributes can create route instances', function () {
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
if ($cachedRegistry === null) {
$this->markTestSkipped('No cached registry found - run discovery first');
}
$routeClass = Route::class;
$routeAttributes = $cachedRegistry->attributes->get($routeClass);
if (empty($routeAttributes)) {
$this->markTestSkipped('No routes found in cache');
}
$firstRoute = $routeAttributes[0];
expect($firstRoute)->toBeInstanceOf(\App\Framework\Discovery\ValueObjects\DiscoveredAttribute::class);
// Try to create Route instance from arguments
try {
$routeInstance = new Route(...$firstRoute->arguments);
expect($routeInstance)->toBeInstanceOf(Route::class);
} catch (\Throwable $e) {
$this->fail("Failed to create Route instance: {$e->getMessage()}");
}
});
test('registry can be serialized and deserialized', function () {
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
if ($cachedRegistry === null) {
$this->markTestSkipped('No cached registry found - run discovery first');
}
$routeClass = Route::class;
$originalRouteCount = count($cachedRegistry->attributes->get($routeClass));
// Serialize and deserialize
$serialized = serialize($cachedRegistry);
$deserialized = unserialize($serialized);
expect($deserialized)->toBeInstanceOf(DiscoveryRegistry::class);
$deserializedRouteCount = count($deserialized->attributes->get($routeClass));
expect($deserializedRouteCount)->toBe($originalRouteCount);
});
test('cached registry content summary is accurate', function () {
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
if ($cachedRegistry === null) {
$this->markTestSkipped('No cached registry found - run discovery first');
}
$summary = $cachedRegistry->getContentSummary();
expect($summary)->toBeArray()
->and($summary)->toHaveKey('routes')
->and($summary)->toHaveKey('commands')
->and($summary)->toHaveKey('initializers');
// Verify route count matches actual count
$routeClass = Route::class;
$actualRouteCount = count($cachedRegistry->attributes->get($routeClass));
expect($summary['routes'])->toBe($actualRouteCount);
});
test('all attribute types are accessible', function () {
$cachedRegistry = $this->cacheManager->get($this->discoveryContext);
if ($cachedRegistry === null) {
$this->markTestSkipped('No cached registry found - run discovery first');
}
$allTypes = $cachedRegistry->attributes->getAllTypes();
expect($allTypes)->toBeArray();
// Verify we can access each type
foreach ($allTypes as $type) {
$attributes = $cachedRegistry->attributes->get($type);
$count = $cachedRegistry->attributes->getCount($type);
expect($attributes)->toBeArray()
->and($count)->toBe(count($attributes));
}
});
test('cache entry can be retrieved directly', function () {
$cacheKey = $this->discoveryContext->getCacheKey();
$cacheResult = $this->cache->get($cacheKey);
if (!$cacheResult->isHit) {
$this->markTestSkipped('No cache entry found - run discovery first');
}
$cachedData = $cacheResult->value;
expect($cachedData)->not->toBeNull();
if (is_array($cachedData)) {
expect($cachedData)->toHaveKey('registry');
}
});

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\Session\CsrfProtection;
use App\Framework\Http\Session\Session;
use App\Framework\Http\Session\SessionId;
use App\Framework\Http\Session\ValueObjects\CsrfDataCollection;
use App\Framework\Http\Session\ValueObjects\FormDataCollection;
use App\Framework\Http\Session\ValueObjects\FlashMessageCollection;
use App\Framework\Http\Session\ValueObjects\SecurityDataCollection;
use App\Framework\Http\Session\ValueObjects\SessionData;
use App\Framework\Http\Session\ValueObjects\ValidationErrorCollection;
use App\Framework\Random\SecureRandomGenerator;
use App\Framework\Security\CsrfToken;
use App\Framework\Security\CsrfTokenGenerator;
beforeEach(function () {
$this->clock = new SystemClock();
$this->tokenGenerator = new CsrfTokenGenerator(new SecureRandomGenerator());
$this->sessionId = SessionId::fromString('test-session-' . uniqid());
$this->session = Session::fromArray(
$this->sessionId,
$this->clock,
$this->tokenGenerator,
[]
);
$this->csrfProtection = $this->session->csrf;
});
it('generates a new token every time', function () {
$formId = 'test-form';
$token1 = $this->csrfProtection->generateToken($formId);
$token2 = $this->csrfProtection->generateToken($formId);
// Tokens should be different (no reuse)
expect($token1->toString())->not->toBe($token2->toString());
});
it('generates valid 64-character hex tokens', function () {
$token = $this->csrfProtection->generateToken('test-form');
expect($token->toString())->toHaveLength(64);
expect(ctype_xdigit($token->toString()))->toBeTrue();
});
it('stores multiple tokens per form', function () {
$formId = 'test-form';
$token1 = $this->csrfProtection->generateToken($formId);
$token2 = $this->csrfProtection->generateToken($formId);
$token3 = $this->csrfProtection->generateToken($formId);
$count = $this->csrfProtection->getActiveTokenCount($formId);
// Should have 3 tokens
expect($count)->toBe(3);
});
it('validates correct token', function () {
$formId = 'test-form';
$token = $this->csrfProtection->generateToken($formId);
$result = $this->csrfProtection->validateTokenWithDebug($formId, $token);
expect($result['valid'])->toBeTrue();
});
it('rejects invalid token', function () {
$formId = 'test-form';
$this->csrfProtection->generateToken($formId);
$invalidToken = CsrfToken::fromString(str_repeat('0', 64));
$result = $this->csrfProtection->validateTokenWithDebug($formId, $invalidToken);
expect($result['valid'])->toBeFalse();
expect($result['debug']['reason'])->toBe('No matching token found in session');
});
it('allows token reuse within resubmit window', function () {
$formId = 'test-form';
$token = $this->csrfProtection->generateToken($formId);
// First validation
$result1 = $this->csrfProtection->validateTokenWithDebug($formId, $token);
expect($result1['valid'])->toBeTrue();
// Second validation within resubmit window (should still work)
$result2 = $this->csrfProtection->validateTokenWithDebug($formId, $token);
expect($result2['valid'])->toBeTrue();
});
it('limits tokens per form to maximum', function () {
$formId = 'test-form';
// Generate more than MAX_TOKENS_PER_FORM (3)
for ($i = 0; $i < 5; $i++) {
$this->csrfProtection->generateToken($formId);
}
$count = $this->csrfProtection->getActiveTokenCount($formId);
// Should be limited to 3
expect($count)->toBeLessThanOrEqual(3);
});
it('handles multiple forms independently', function () {
$formId1 = 'form-1';
$formId2 = 'form-2';
$token1 = $this->csrfProtection->generateToken($formId1);
$token2 = $this->csrfProtection->generateToken($formId2);
// Tokens should be different
expect($token1->toString())->not->toBe($token2->toString());
// Each form should have its own token
expect($this->csrfProtection->getActiveTokenCount($formId1))->toBe(1);
expect($this->csrfProtection->getActiveTokenCount($formId2))->toBe(1);
// Validation should work independently
expect($this->csrfProtection->validateToken($formId1, $token1))->toBeTrue();
expect($this->csrfProtection->validateToken($formId2, $token2))->toBeTrue();
// Cross-validation should fail
expect($this->csrfProtection->validateToken($formId1, $token2))->toBeFalse();
expect($this->csrfProtection->validateToken($formId2, $token1))->toBeFalse();
});

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\Session\FileSessionStorage;
use App\Framework\Http\Session\InMemorySessionStorage;
use App\Framework\Http\Session\SessionId;
beforeEach(function () {
$this->tempDir = sys_get_temp_dir() . '/php_sessions_test_' . uniqid();
mkdir($this->tempDir, 0700, true);
$this->clock = new SystemClock();
$this->fileStorage = new FileSessionStorage($this->tempDir, $this->clock);
$this->memoryStorage = new InMemorySessionStorage();
$this->sessionId = SessionId::fromString('test-session-' . uniqid());
});
afterEach(function () {
// Cleanup
if (isset($this->tempDir) && is_dir($this->tempDir)) {
array_map('unlink', glob($this->tempDir . '/*'));
rmdir($this->tempDir);
}
});
it('acquires and releases lock for file storage', function () {
$acquired = $this->fileStorage->acquireLock($this->sessionId, 1);
expect($acquired)->toBeTrue();
// Should be able to release
$this->fileStorage->releaseLock($this->sessionId);
// Should be able to acquire again
$acquired2 = $this->fileStorage->acquireLock($this->sessionId, 1);
expect($acquired2)->toBeTrue();
$this->fileStorage->releaseLock($this->sessionId);
});
it('prevents concurrent locks for file storage', function () {
// Acquire lock in first "process"
$acquired1 = $this->fileStorage->acquireLock($this->sessionId, 1);
expect($acquired1)->toBeTrue();
// Try to acquire same lock in second "process" (should fail or timeout)
$acquired2 = $this->fileStorage->acquireLock($this->sessionId, 1);
// In single-threaded test, second acquisition might succeed
// But in real scenario with concurrent processes, it would fail
// This test verifies the locking mechanism exists
$this->fileStorage->releaseLock($this->sessionId);
});
it('acquires and releases lock for memory storage', function () {
$acquired = $this->memoryStorage->acquireLock($this->sessionId, 1);
expect($acquired)->toBeTrue();
// Should be able to release
$this->memoryStorage->releaseLock($this->sessionId);
// Should be able to acquire again
$acquired2 = $this->memoryStorage->acquireLock($this->sessionId, 1);
expect($acquired2)->toBeTrue();
$this->memoryStorage->releaseLock($this->sessionId);
});
it('prevents concurrent locks for memory storage', function () {
// Acquire lock
$acquired1 = $this->memoryStorage->acquireLock($this->sessionId, 1);
expect($acquired1)->toBeTrue();
// Try to acquire same lock (should fail)
$acquired2 = $this->memoryStorage->acquireLock($this->sessionId, 1);
expect($acquired2)->toBeFalse();
// Release and try again
$this->memoryStorage->releaseLock($this->sessionId);
$acquired3 = $this->memoryStorage->acquireLock($this->sessionId, 1);
expect($acquired3)->toBeTrue();
$this->memoryStorage->releaseLock($this->sessionId);
});
it('handles lock timeout correctly', function () {
// Acquire lock
$acquired1 = $this->memoryStorage->acquireLock($this->sessionId, 1);
expect($acquired1)->toBeTrue();
// Try to acquire with very short timeout (should fail)
$acquired2 = $this->memoryStorage->acquireLock($this->sessionId, 0);
expect($acquired2)->toBeFalse();
$this->memoryStorage->releaseLock($this->sessionId);
});

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Attributes;
use App\Framework\LiveComponents\Attributes\Island;
use PHPUnit\Framework\TestCase;
final class IslandTest extends TestCase
{
public function test_can_create_island_attribute_with_defaults(): void
{
$island = new Island();
$this->assertTrue($island->isolated);
$this->assertFalse($island->lazy);
$this->assertNull($island->placeholder);
}
public function test_can_create_island_attribute_with_custom_values(): void
{
$island = new Island(
isolated: true,
lazy: true,
placeholder: 'Loading widget...'
);
$this->assertTrue($island->isolated);
$this->assertTrue($island->lazy);
$this->assertSame('Loading widget...', $island->placeholder);
}
public function test_can_create_non_isolated_island(): void
{
$island = new Island(isolated: false);
$this->assertFalse($island->isolated);
$this->assertFalse($island->lazy);
$this->assertNull($island->placeholder);
}
public function test_can_create_lazy_island_without_placeholder(): void
{
$island = new Island(lazy: true);
$this->assertTrue($island->isolated);
$this->assertTrue($island->lazy);
$this->assertNull($island->placeholder);
}
public function test_can_create_lazy_island_with_placeholder(): void
{
$island = new Island(
lazy: true,
placeholder: 'Please wait...'
);
$this->assertTrue($island->isolated);
$this->assertTrue($island->lazy);
$this->assertSame('Please wait...', $island->placeholder);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Middleware;
use App\Framework\DI\Container;
use App\Framework\DI\DefaultContainer;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Middleware\ComponentMiddlewarePipeline;
use App\Framework\LiveComponents\Middleware\MiddlewareRegistration;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentState;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
use App\Framework\LiveComponents\ValueObjects\LiveComponentState;
use Tests\Unit\Framework\LiveComponents\Middleware\TestMiddleware1;
use Tests\Unit\Framework\LiveComponents\Middleware\TestMiddleware2;
use Tests\Unit\Framework\LiveComponents\Middleware\TestPassThroughMiddleware;
use Tests\Unit\Framework\LiveComponents\Middleware\TestCaptureMiddleware;
// Ensure test middleware classes are loaded
require_once __DIR__ . '/TestMiddleware.php';
describe('ComponentMiddlewarePipeline', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
});
it('executes middleware in priority order', function () {
$executionOrder = [];
// Register middleware in container
$this->container->instance(TestMiddleware1::class, new TestMiddleware1($executionOrder));
$this->container->instance(TestMiddleware2::class, new TestMiddleware2($executionOrder));
// Create pipeline with middleware (higher priority first)
$middlewares = [
new MiddlewareRegistration(TestMiddleware1::class, priority: 200),
new MiddlewareRegistration(TestMiddleware2::class, priority: 100),
];
$pipeline = new ComponentMiddlewarePipeline($middlewares, $this->container);
// Create test component
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
};
// Execute pipeline
$result = $pipeline->process(
$component,
'testAction',
ActionParameters::fromArray([]),
fn($c, $a, $p) => new ComponentUpdate(
component: $c,
state: LiveComponentState::fromArray([]),
events: []
)
);
// Middleware should execute in priority order (higher first)
expect($executionOrder)->toBe(['middleware1', 'middleware2']);
});
it('passes component, action, and params through middleware chain', function () {
$receivedComponent = null;
$receivedAction = null;
$receivedParams = null;
$middleware = new TestCaptureMiddleware($receivedComponent, $receivedAction, $receivedParams);
$this->container->instance(TestCaptureMiddleware::class, $middleware);
$middlewares = [
new MiddlewareRegistration(TestCaptureMiddleware::class, priority: 100),
];
$pipeline = new ComponentMiddlewarePipeline($middlewares, $this->container);
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
};
$params = ActionParameters::fromArray(['test' => 'value']);
$pipeline->process(
$component,
'testAction',
$params,
fn($c, $a, $p) => new ComponentUpdate(
component: $c,
state: LiveComponentState::fromArray([]),
events: []
)
);
expect($receivedComponent)->toBe($component);
expect($receivedAction)->toBe('testAction');
expect($receivedParams)->toBe($params);
});
it('returns action handler result', function () {
$expectedResult = new ComponentUpdate(
component: new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
},
state: LiveComponentState::fromArray(['result' => 'success']),
events: []
);
$middleware = new TestPassThroughMiddleware();
$this->container->instance(TestPassThroughMiddleware::class, $middleware);
$middlewares = [
new MiddlewareRegistration(TestPassThroughMiddleware::class, priority: 100),
];
$pipeline = new ComponentMiddlewarePipeline($middlewares, $this->container);
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
};
$result = $pipeline->process(
$component,
'testAction',
ActionParameters::fromArray([]),
fn($c, $a, $p) => $expectedResult
);
expect($result)->toBe($expectedResult);
});
it('handles empty middleware array', function () {
$pipeline = new ComponentMiddlewarePipeline([], $this->container);
$component = new class(ComponentId::create('test', 'demo'), ComponentState::empty()) implements LiveComponentContract {
public function __construct(public ComponentId $id, public ComponentState $state) {}
public function getRenderData() {
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData('test', []);
}
};
$expectedResult = new ComponentUpdate(
component: $component,
state: LiveComponentState::fromArray([]),
events: []
);
$result = $pipeline->process(
$component,
'testAction',
ActionParameters::fromArray([]),
fn($c, $a, $p) => $expectedResult
);
expect($result)->toBe($expectedResult);
});
});

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Middleware;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\Discovery\Results\AttributeRegistry;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\Results\InterfaceRegistry;
use App\Framework\Discovery\Results\TemplateRegistry;
use App\Framework\Discovery\ValueObjects\AttributeTarget;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\LiveComponents\Attributes\Middleware as MiddlewareAttribute;
use App\Framework\LiveComponents\Middleware\MiddlewareCollector;
use App\Framework\LiveComponents\Middleware\LoggingMiddleware;
describe('MiddlewareCollector', function () {
beforeEach(function () {
$this->attributeRegistry = new AttributeRegistry();
$this->discoveryRegistry = new DiscoveryRegistry(
attributes: $this->attributeRegistry,
interfaces: new InterfaceRegistry([]),
templates: new TemplateRegistry([])
);
$this->collector = new MiddlewareCollector($this->discoveryRegistry);
});
it('collects component-level middleware', function () {
$componentClass = ClassName::create('Test\\Component');
// Add component-level middleware attribute
$discovered = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [LoggingMiddleware::class, 100]
);
$this->attributeRegistry->add(MiddlewareAttribute::class, $discovered);
$middlewares = $this->collector->collectForAction(
$componentClass,
MethodName::create('testAction')
);
expect($middlewares)->toHaveCount(1);
expect($middlewares[0]->middlewareClass)->toBe(LoggingMiddleware::class);
expect($middlewares[0]->priority)->toBe(100);
});
it('collects action-level middleware', function () {
$componentClass = ClassName::create('Test\\Component');
$actionMethod = MethodName::create('testAction');
// Add action-level middleware attribute
$discovered = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_METHOD,
methodName: $actionMethod,
arguments: [LoggingMiddleware::class, 200]
);
$this->attributeRegistry->add(MiddlewareAttribute::class, $discovered);
$middlewares = $this->collector->collectForAction(
$componentClass,
$actionMethod
);
expect($middlewares)->toHaveCount(1);
expect($middlewares[0]->middlewareClass)->toBe(LoggingMiddleware::class);
expect($middlewares[0]->priority)->toBe(200);
});
it('combines component and action-level middleware', function () {
$componentClass = ClassName::create('Test\\Component');
$actionMethod = MethodName::create('testAction');
// Add component-level middleware
$componentMiddleware = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [LoggingMiddleware::class, 100]
);
// Add action-level middleware
$actionMiddleware = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_METHOD,
methodName: $actionMethod,
arguments: [\App\Framework\LiveComponents\Middleware\CachingMiddleware::class, 200]
);
$this->attributeRegistry->add(MiddlewareAttribute::class, $componentMiddleware);
$this->attributeRegistry->add(MiddlewareAttribute::class, $actionMiddleware);
$middlewares = $this->collector->collectForAction(
$componentClass,
$actionMethod
);
expect($middlewares)->toHaveCount(2);
// Should be sorted by priority (higher first)
expect($middlewares[0]->priority)->toBe(200);
expect($middlewares[1]->priority)->toBe(100);
});
it('sorts middleware by priority descending', function () {
$componentClass = ClassName::create('Test\\Component');
// Add multiple middleware with different priorities
$middleware1 = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [LoggingMiddleware::class, 50]
);
$middleware2 = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [\App\Framework\LiveComponents\Middleware\CachingMiddleware::class, 150]
);
$middleware3 = new DiscoveredAttribute(
className: $componentClass,
attributeClass: MiddlewareAttribute::class,
target: AttributeTarget::TARGET_CLASS,
arguments: [\App\Framework\LiveComponents\Middleware\RateLimitMiddleware::class, 100]
);
$this->attributeRegistry->add(MiddlewareAttribute::class, $middleware1);
$this->attributeRegistry->add(MiddlewareAttribute::class, $middleware2);
$this->attributeRegistry->add(MiddlewareAttribute::class, $middleware3);
$middlewares = $this->collector->collectForAction(
$componentClass,
MethodName::create('testAction')
);
expect($middlewares)->toHaveCount(3);
// Should be sorted by priority descending
expect($middlewares[0]->priority)->toBe(150);
expect($middlewares[1]->priority)->toBe(100);
expect($middlewares[2]->priority)->toBe(50);
});
it('returns empty array when no middleware found', function () {
$componentClass = ClassName::create('Test\\Component');
$middlewares = $this->collector->collectForAction(
$componentClass,
MethodName::create('testAction')
);
expect($middlewares)->toBeEmpty();
});
});

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Middleware;
use App\Framework\LiveComponents\Middleware\ComponentMiddlewareInterface;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
use App\Framework\LiveComponents\ValueObjects\LiveComponentState;
use Tests\Unit\Framework\LiveComponents\Middleware\TestMiddleware1;
it('can create middleware', function () {
$order = [];
$middleware = new TestMiddleware1($order);
expect($middleware)->toBeInstanceOf(ComponentMiddlewareInterface::class);
});

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Middleware;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Middleware\ComponentMiddlewareInterface;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentUpdate;
/**
* Test Middleware for unit tests
*/
final class TestMiddleware1 implements ComponentMiddlewareInterface
{
private array $executionOrder;
public function __construct(
array &$executionOrder
) {
$this->executionOrder = &$executionOrder;
}
public function handle(
LiveComponentContract $component,
string $action,
ActionParameters $params,
callable $next
): ComponentUpdate {
$this->executionOrder[] = 'middleware1';
return $next($component, $action, $params);
}
}
final class TestMiddleware2 implements ComponentMiddlewareInterface
{
private array $executionOrder;
public function __construct(
array &$executionOrder
) {
$this->executionOrder = &$executionOrder;
}
public function handle(
LiveComponentContract $component,
string $action,
ActionParameters $params,
callable $next
): ComponentUpdate {
$this->executionOrder[] = 'middleware2';
return $next($component, $action, $params);
}
}
final class TestPassThroughMiddleware implements ComponentMiddlewareInterface
{
public function handle(
LiveComponentContract $component,
string $action,
ActionParameters $params,
callable $next
): ComponentUpdate {
return $next($component, $action, $params);
}
}
final class TestCaptureMiddleware implements ComponentMiddlewareInterface
{
private ?LiveComponentContract $capturedComponent;
private ?string $capturedAction;
private ?ActionParameters $capturedParams;
public function __construct(
?LiveComponentContract &$capturedComponent,
?string &$capturedAction,
?ActionParameters &$capturedParams
) {
$this->capturedComponent = &$capturedComponent;
$this->capturedAction = &$capturedAction;
$this->capturedParams = &$capturedParams;
}
public function handle(
LiveComponentContract $component,
string $action,
ActionParameters $params,
callable $next
): ComponentUpdate {
$this->capturedComponent = $component;
$this->capturedAction = $action;
$this->capturedParams = $params;
return $next($component, $action, $params);
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Performance;
use App\Application\LiveComponents\Counter\CounterComponent;
use App\Framework\LiveComponents\Attributes\Island;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Performance\ComponentMetadataCompiler;
use App\Framework\LiveComponents\Performance\CompiledComponentMetadata;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentState;
describe('Island Metadata Detection', function () {
it('detects Island attribute in component', function () {
$compiler = new ComponentMetadataCompiler();
// Create a test component with Island attribute
$componentClass = IslandTestComponent::class;
$metadata = $compiler->compile($componentClass);
expect($metadata->isIsland())->toBeTrue();
expect($metadata->getIsland())->not->toBeNull();
$island = $metadata->getIsland();
expect($island['isolated'])->toBeTrue();
expect($island['lazy'])->toBeFalse();
expect($island['placeholder'])->toBeNull();
});
it('detects Island attribute with lazy loading', function () {
$compiler = new ComponentMetadataCompiler();
$componentClass = LazyIslandTestComponent::class;
$metadata = $compiler->compile($componentClass);
expect($metadata->isIsland())->toBeTrue();
$island = $metadata->getIsland();
expect($island['isolated'])->toBeTrue();
expect($island['lazy'])->toBeTrue();
expect($island['placeholder'])->toBe('Loading component...');
});
it('returns null for non-island components', function () {
$compiler = new ComponentMetadataCompiler();
$metadata = $compiler->compile(CounterComponent::class);
expect($metadata->isIsland())->toBeFalse();
expect($metadata->getIsland())->toBeNull();
});
it('serializes Island metadata in toArray', function () {
$compiler = new ComponentMetadataCompiler();
$metadata = $compiler->compile(IslandTestComponent::class);
$array = $metadata->toArray();
expect($array)->toHaveKey('island');
expect($array['island'])->not->toBeNull();
expect($array['island']['isolated'])->toBeTrue();
expect($array['island']['lazy'])->toBeFalse();
});
it('deserializes Island metadata from array', function () {
$compiler = new ComponentMetadataCompiler();
$metadata = $compiler->compile(IslandTestComponent::class);
$array = $metadata->toArray();
$restored = CompiledComponentMetadata::fromArray($array);
expect($restored->isIsland())->toBeTrue();
expect($restored->getIsland())->not->toBeNull();
$island = $restored->getIsland();
expect($island['isolated'])->toBeTrue();
expect($island['lazy'])->toBeFalse();
});
});
// Test component classes
#[LiveComponent('island-test')]
#[Island]
final readonly class IslandTestComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public ComponentState $state
) {
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData(
templatePath: 'test',
data: []
);
}
}
#[LiveComponent('lazy-island-test')]
#[Island(isolated: true, lazy: true, placeholder: 'Loading component...')]
final readonly class LazyIslandTestComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public ComponentState $state
) {
}
public function getRenderData(): \App\Framework\LiveComponents\ValueObjects\ComponentRenderData
{
return new \App\Framework\LiveComponents\ValueObjects\ComponentRenderData(
templatePath: 'test',
data: []
);
}
}

View File

@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
require_once __DIR__ . '/TestComponents.php';
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\DefaultContainer;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Security\ActionValidator;
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use Tests\Unit\Framework\LiveComponents\Security\ArrayReturnTypeComponent;
use Tests\Unit\Framework\LiveComponents\Security\PrimitiveReturnTypeComponent;
use Tests\Unit\Framework\LiveComponents\Security\PrivateActionComponent;
use Tests\Unit\Framework\LiveComponents\Security\StaticActionComponent;
use Tests\Unit\Framework\LiveComponents\Security\ValidActionComponent;
use PHPUnit\Framework\TestCase;
final class ActionValidatorTest extends TestCase
{
private ActionValidator $validator;
private DefaultContainer $container;
protected function setUp(): void
{
parent::setUp();
$this->container = new DefaultContainer();
$this->validator = new ActionValidator();
}
public function test_validates_valid_action(): void
{
$component = $this->createValidComponent();
$context = $this->createContext($component, 'validAction');
$actionAttribute = new Action();
// Should not throw
$this->validator->validate($context, $actionAttribute);
$this->assertTrue(true);
}
public function test_rejects_reserved_method(): void
{
$component = $this->createValidComponent();
$context = $this->createContext($component, 'onMount');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('reserved method');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_non_existent_method(): void
{
$component = $this->createValidComponent();
$context = $this->createContext($component, 'nonExistentMethod');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('not found');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_private_method(): void
{
$component = new PrivateActionComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
$context = $this->createContext($component, 'privateAction');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('must be public');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_static_method(): void
{
$component = new StaticActionComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
$context = $this->createContext($component, 'staticAction');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('cannot be static');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_primitive_return_type(): void
{
$component = new PrimitiveReturnTypeComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
$context = $this->createContext($component, 'intAction');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('must return a State object');
$this->validator->validate($context, $actionAttribute);
}
public function test_rejects_array_return_type(): void
{
$component = new ArrayReturnTypeComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
$context = $this->createContext($component, 'arrayAction');
$actionAttribute = new Action();
$this->expectException(\BadMethodCallException::class);
$this->expectExceptionMessage('must return a State object');
$this->validator->validate($context, $actionAttribute);
}
private function createValidComponent(): LiveComponentContract
{
return new ValidActionComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
}
private function createContext(LiveComponentContract $component, string $methodName): AttributeExecutionContext
{
$componentClass = ClassName::create($component::class);
$method = MethodName::create($methodName);
$componentId = $component->id;
$actionParameters = ActionParameters::fromArray([]);
return LiveComponentContextHelper::createForAction(
container: $this->container,
componentClass: $componentClass,
actionMethod: $method,
componentId: $componentId,
actionParameters: $actionParameters,
component: $component
);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\Session\SessionInterface;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Security\Guards\LiveComponentCsrfGuard;
use App\Framework\LiveComponents\Security\LiveComponentContextData;
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use App\Framework\Http\Session\CsrfProtection;
use App\Framework\Security\CsrfToken;
use App\Framework\Security\Guards\CsrfGuard;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class LiveComponentCsrfGuardTest extends TestCase
{
private LiveComponentCsrfGuard $guard;
private CsrfGuard $csrfGuard;
private SessionInterface&MockObject $session;
private CsrfProtection&MockObject $csrfProtection;
private DefaultContainer $container;
protected function setUp(): void
{
parent::setUp();
// Since CsrfProtection is final, we skip mocking it
// These tests focus on the LiveComponent-specific logic (context extraction, form ID generation)
// The actual CSRF validation is tested in integration tests
$this->session = $this->createMock(SessionInterface::class);
$this->csrfGuard = new CsrfGuard($this->session);
$this->container = new DefaultContainer();
$this->container->instance(CsrfGuard::class, $this->csrfGuard);
$this->container->instance(SessionInterface::class, $this->session);
$this->guard = new LiveComponentCsrfGuard($this->csrfGuard);
}
public function test_validates_valid_csrf_token(): void
{
// This test is skipped because CsrfProtection is final and cannot be mocked
// The CSRF validation logic is tested in integration tests
$this->markTestSkipped('CsrfProtection is final and cannot be mocked. Tested in integration tests.');
}
public function test_rejects_missing_csrf_token(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('CSRF token is required');
$this->guard->validate($context);
}
public function test_rejects_context_without_live_component_data(): void
{
$context = AttributeExecutionContext::forMethod(
container: $this->container,
className: ClassName::create('TestComponent'),
methodName: MethodName::create('testMethod')
);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('LiveComponentContextData');
$this->guard->validate($context);
}
public function test_uses_component_id_as_form_id(): void
{
// This test is skipped because CsrfProtection is final and cannot be mocked
// The form ID generation logic is tested in integration tests
$this->markTestSkipped('CsrfProtection is final and cannot be mocked. Tested in integration tests.');
}
private function createComponent(): LiveComponentContract
{
return new TestComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
}
private function createContext(LiveComponentContract $component, ActionParameters $params): AttributeExecutionContext
{
return LiveComponentContextHelper::createForAction(
container: $this->container,
componentClass: ClassName::create($component::class),
actionMethod: MethodName::create('testAction'),
componentId: $component->id,
actionParameters: $params,
component: $component
);
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
require_once __DIR__ . '/TestComponents.php';
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\DefaultContainer;
use App\Framework\LiveComponents\Attributes\RequiresPermission;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Exceptions\UnauthorizedActionException;
use App\Framework\LiveComponents\Security\ActionAuthorizationChecker;
use App\Framework\LiveComponents\Security\Guards\LiveComponentPermissionGuard;
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class LiveComponentPermissionGuardTest extends TestCase
{
private LiveComponentPermissionGuard $guard;
private ActionAuthorizationChecker&MockObject $authorizationChecker;
private DefaultContainer $container;
protected function setUp(): void
{
parent::setUp();
$this->authorizationChecker = $this->createMock(ActionAuthorizationChecker::class);
$this->container = new DefaultContainer();
$this->container->instance(ActionAuthorizationChecker::class, $this->authorizationChecker);
$this->guard = new LiveComponentPermissionGuard($this->authorizationChecker);
}
public function test_allows_authorized_action(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$permissionAttribute = new RequiresPermission('edit_post');
$this->authorizationChecker
->expects($this->once())
->method('isAuthorized')
->with($component, 'testAction', $permissionAttribute)
->willReturn(true);
// Should not throw
$this->guard->check($context, $permissionAttribute);
$this->assertTrue(true);
}
public function test_rejects_unauthorized_action(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$permissionAttribute = new RequiresPermission('edit_post');
$this->authorizationChecker
->expects($this->once())
->method('isAuthorized')
->willReturn(false);
$this->authorizationChecker
->expects($this->once())
->method('isAuthenticated')
->willReturn(true);
$this->authorizationChecker
->expects($this->once())
->method('getUserPermissions')
->willReturn(['view_post']);
$this->expectException(UnauthorizedActionException::class);
$this->guard->check($context, $permissionAttribute);
}
public function test_rejects_unauthenticated_user(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$permissionAttribute = new RequiresPermission('edit_post');
$this->authorizationChecker
->expects($this->once())
->method('isAuthorized')
->willReturn(false);
$this->authorizationChecker
->expects($this->once())
->method('isAuthenticated')
->willReturn(false);
$this->expectException(UnauthorizedActionException::class);
$this->expectExceptionMessage('requires authentication');
$this->guard->check($context, $permissionAttribute);
}
public function test_rejects_context_without_live_component_data(): void
{
$context = AttributeExecutionContext::forMethod(
container: $this->container,
className: ClassName::create('TestComponent'),
methodName: MethodName::create('testMethod')
);
$permissionAttribute = new RequiresPermission('edit_post');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('LiveComponentContextData');
$this->guard->check($context, $permissionAttribute);
}
private function createComponent(): LiveComponentContract
{
return new TestComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
}
private function createContext(LiveComponentContract $component, ActionParameters $params): AttributeExecutionContext
{
return LiveComponentContextHelper::createForAction(
container: $this->container,
componentClass: ClassName::create($component::class),
actionMethod: MethodName::create('testAction'),
componentId: $component->id,
actionParameters: $params,
component: $component
);
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
require_once __DIR__ . '/TestComponents.php';
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\Attributes\Execution\AttributeExecutionContext;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\Core\ValueObjects\ClientIdentifier;
use App\Framework\Core\ValueObjects\MethodName;
use App\Framework\DI\DefaultContainer;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Exceptions\RateLimitExceededException;
use App\Framework\LiveComponents\Security\Guards\LiveComponentRateLimitGuard;
use App\Framework\LiveComponents\Security\LiveComponentContextHelper;
use App\Framework\LiveComponents\Services\LiveComponentRateLimiter;
use App\Framework\LiveComponents\Services\RateLimitResult;
use App\Framework\LiveComponents\ValueObjects\ActionParameters;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Cache\Driver\InMemoryCache;
use App\Framework\Cache\GeneralCache;
use App\Framework\Serializer\Php\PhpSerializer;
use App\Framework\Serializer\Php\PhpSerializerConfig;
use App\Framework\RateLimit\RateLimiter;
use App\Framework\RateLimit\Storage\CacheStorage;
use Tests\Unit\Framework\LiveComponents\Security\TestComponent;
use PHPUnit\Framework\TestCase;
final class LiveComponentRateLimitGuardTest extends TestCase
{
private LiveComponentRateLimitGuard $guard;
private LiveComponentRateLimiter $rateLimiter;
private DefaultContainer $container;
protected function setUp(): void
{
parent::setUp();
// Use real instance since LiveComponentRateLimiter is final
$this->container = new DefaultContainer();
$cacheDriver = new InMemoryCache();
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
$cache = new GeneralCache($cacheDriver, $serializer);
$storage = new CacheStorage($cache);
$baseRateLimiter = new RateLimiter($storage);
$this->container->instance(\App\Framework\RateLimit\RateLimiter::class, $baseRateLimiter);
$this->rateLimiter = new LiveComponentRateLimiter($baseRateLimiter);
$this->guard = new LiveComponentRateLimitGuard($this->rateLimiter);
}
public function test_skips_check_when_no_client_identifier(): void
{
$component = $this->createComponent();
$params = ActionParameters::fromArray([]);
$context = $this->createContext($component, $params);
$actionAttribute = new Action(rateLimit: 10);
// Should not throw and not call rate limiter (no client identifier)
$this->guard->check($context, $actionAttribute);
$this->assertTrue(true);
}
public function test_rejects_context_without_live_component_data(): void
{
$context = AttributeExecutionContext::forMethod(
container: $this->container,
className: ClassName::create('TestComponent'),
methodName: MethodName::create('testMethod')
);
$actionAttribute = new Action();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('LiveComponentContextData');
$this->guard->check($context, $actionAttribute);
}
private function createComponent(): LiveComponentContract
{
return new TestComponent(
ComponentId::create('test', 'demo'),
CounterState::empty()
);
}
private function createContext(LiveComponentContract $component, ActionParameters $params): AttributeExecutionContext
{
return LiveComponentContextHelper::createForAction(
container: $this->container,
componentClass: ClassName::create($component::class),
actionMethod: MethodName::create('testAction'),
componentId: $component->id,
actionParameters: $params,
component: $component
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
final class TestComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\Security;
use App\Application\LiveComponents\Counter\CounterState;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
/**
* Test Component with valid action
*/
final class ValidActionComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
public function validAction(): CounterState
{
return $this->state;
}
}
/**
* Test Component with private action (should fail validation)
*/
final class PrivateActionComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
private function privateAction(): CounterState
{
return $this->state;
}
}
/**
* Test Component with static action (should fail validation)
*/
final class StaticActionComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
public static function staticAction(): CounterState
{
return CounterState::empty();
}
}
/**
* Test Component with primitive return type (should fail validation)
*/
final class PrimitiveReturnTypeComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
public function intAction(): int
{
return 42;
}
}
/**
* Test Component with array return type (should fail validation)
*/
final class ArrayReturnTypeComponent implements LiveComponentContract
{
public function __construct(
public ComponentId $id,
public CounterState $state
) {
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(templatePath: 'test', data: []);
}
#[Action]
public function arrayAction(): array
{
return [];
}
}

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\LiveComponents\UI;
use App\Framework\LiveComponents\ComponentEventDispatcher;
use App\Framework\LiveComponents\UI\UIHelper;
use App\Framework\LiveComponents\ValueObjects\ComponentEvent;
use PHPUnit\Framework\TestCase;
/**
* Test class for UIHelper
*/
final class UIHelperTest extends TestCase
{
public function testShowToastDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->toast('Test message', 'success', null);
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertInstanceOf(ComponentEvent::class, $event);
$this->assertEquals('toast:show', $event->name);
$this->assertTrue($event->isBroadcast());
$payload = $event->payload;
$this->assertEquals('Test message', $payload->getString('message'));
$this->assertEquals('success', $payload->getString('type'));
}
public function testShowToastWithDefaults(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->toast('Test message');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('info', $payload->getString('type'));
$this->assertEquals(5000, $payload->getInt('duration'));
$this->assertEquals('top-right', $payload->getString('position'));
$this->assertEquals('global', $payload->getString('componentId'));
}
public function testShowToastWithNullEvents(): void
{
$ui = new UIHelper(null);
// Should not throw error
$ui->toast('Test message');
$this->assertTrue(true); // Test passes if no exception thrown
}
public function testSuccessToast(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->successToast('Success message');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('success', $payload->getString('type'));
$this->assertEquals('Success message', $payload->getString('message'));
}
public function testErrorToast(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->errorToast('Error message');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('error', $payload->getString('type'));
$this->assertEquals(0, $payload->getInt('duration')); // Persistent by default
}
public function testHideToastDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->hideToast('test-component');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('toast:hide', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
}
public function testShowModalDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->modal(
'test-component',
'Test Title',
'<p>Test content</p>',
\App\Framework\LiveComponents\Events\UI\Options\ModalOptions::create()
->withSize(\App\Framework\LiveComponents\Events\UI\Enums\ModalSize::Large)
->withButtons([['text' => 'OK', 'class' => 'btn-primary']])
->closeOnBackdrop(false)
->closeOnEscape(false)
);
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('modal:show', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
$this->assertEquals('Test Title', $payload->getString('title'));
$this->assertEquals('<p>Test content</p>', $payload->getString('content'));
$this->assertEquals('large', $payload->getString('size'));
$this->assertFalse($payload->getBool('closeOnBackdrop'));
$this->assertFalse($payload->getBool('closeOnEscape'));
}
public function testShowModalWithDefaults(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->modal('test-component', 'Title', 'Content');
$dispatchedEvents = $events->getEvents();
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('medium', $payload->getString('size'));
$this->assertTrue($payload->getBool('closeOnBackdrop'));
$this->assertTrue($payload->getBool('closeOnEscape'));
}
public function testCloseModalDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->closeModal('test-component');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('modal:close', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
}
public function testShowConfirmDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->confirm(
'test-component',
'Confirm Title',
'Confirm message',
\App\Framework\LiveComponents\Events\UI\Options\ConfirmOptions::create()
->withButtons('Yes', 'No')
->withClasses('btn-danger', 'btn-secondary')
);
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('modal:confirm', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
$this->assertEquals('Confirm Title', $payload->getString('title'));
$this->assertEquals('Confirm message', $payload->getString('message'));
$this->assertEquals('Yes', $payload->getString('confirmText'));
$this->assertEquals('No', $payload->getString('cancelText'));
}
public function testShowConfirmWithDefaults(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->confirm('test-component', 'Title', 'Message');
$dispatchedEvents = $events->getEvents();
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('Confirm', $payload->getString('confirmText'));
$this->assertEquals('Cancel', $payload->getString('cancelText'));
$this->assertEquals('btn-primary', $payload->getString('confirmClass'));
$this->assertEquals('btn-secondary', $payload->getString('cancelClass'));
}
public function testConfirmDelete(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->confirmDelete('test-component', 'Item Name', 'deleteAction', ['id' => 123]);
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('Delete Item Name?', $payload->getString('title'));
$this->assertEquals("Are you sure you want to delete 'Item Name'? This action cannot be undone.", $payload->getString('message'));
$this->assertEquals('Delete', $payload->getString('confirmText'));
$this->assertEquals('btn-danger', $payload->getString('confirmClass'));
$this->assertEquals('deleteAction', $payload->getString('confirmAction'));
$this->assertEquals(['id' => 123], $payload->getArray('confirmParams'));
}
public function testShowAlertDispatchesEvent(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->alert('test-component', 'Alert Title', 'Alert message', 'error', 'OK');
$dispatchedEvents = $events->getEvents();
$this->assertCount(1, $dispatchedEvents);
$event = $dispatchedEvents[0];
$this->assertEquals('modal:alert', $event->name);
$payload = $event->payload;
$this->assertEquals('test-component', $payload->getString('componentId'));
$this->assertEquals('Alert Title', $payload->getString('title'));
$this->assertEquals('Alert message', $payload->getString('message'));
$this->assertEquals('error', $payload->getString('type'));
$this->assertEquals('OK', $payload->getString('buttonText'));
}
public function testShowAlertWithDefaults(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$ui->alert('test-component', 'Title', 'Message');
$dispatchedEvents = $events->getEvents();
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('info', $payload->getString('type'));
$this->assertEquals('OK', $payload->getString('buttonText'));
}
public function testFluentInterface(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$result = $ui->successToast('Saved!')
->infoToast('Processing...')
->modal('modal-1', 'Title', 'Content');
$this->assertSame($ui, $result);
$this->assertCount(3, $events->getEvents());
}
public function testComponentIdAsValueObject(): void
{
$events = new ComponentEventDispatcher();
$ui = new UIHelper($events);
$componentId = \App\Framework\LiveComponents\ValueObjects\ComponentId::fromString('test:component');
$ui->modal($componentId, 'Title', 'Content');
$dispatchedEvents = $events->getEvents();
$event = $dispatchedEvents[0];
$payload = $event->payload;
$this->assertEquals('test:component', $payload->getString('componentId'));
}
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\Template\Expression;
use App\Framework\Template\Expression\ExpressionEvaluator;
describe('ExpressionEvaluator', function () {
beforeEach(function () {
$this->evaluator = new ExpressionEvaluator();
});
describe('nested array access', function () {
it('supports single level array access', function () {
$context = [
'item' => ['name' => 'Test'],
];
$result = $this->evaluator->evaluate("item['name']", $context);
expect($result)->toBe('Test');
});
it('supports nested array access', function () {
$context = [
'item' => [
'user' => [
'name' => 'John',
'email' => 'john@example.com',
],
],
];
$result = $this->evaluator->evaluate("item['user']['name']", $context);
expect($result)->toBe('John');
});
it('supports nested array access with $ prefix', function () {
$context = [
'item' => [
'user' => [
'name' => 'John',
],
],
];
$result = $this->evaluator->evaluate("\$item['user']['name']", $context);
expect($result)->toBe('John');
});
it('returns null for missing nested keys', function () {
$context = [
'item' => ['name' => 'Test'],
];
$result = $this->evaluator->evaluate("item['user']['name']", $context);
expect($result)->toBeNull();
});
it('supports numeric indices in nested arrays', function () {
$context = [
'items' => [
[0 => ['name' => 'First']],
[0 => ['name' => 'Second']],
],
];
$result = $this->evaluator->evaluate("items[0][0]['name']", $context);
expect($result)->toBe('First');
});
});
describe('fallback mechanism', function () {
it('supports null coalescing operator', function () {
$context = [
'name' => null,
];
// Note: This is handled by ForTransformer/PlaceholderTransformer, not ExpressionEvaluator
// ExpressionEvaluator just evaluates the expression
$result = $this->evaluator->evaluate("name", $context);
expect($result)->toBeNull();
});
});
describe('error handling', function () {
it('returns null for missing variables', function () {
$context = [];
$result = $this->evaluator->evaluate('missingVar', $context);
expect($result)->toBeNull();
});
it('returns null for missing array keys', function () {
$context = [
'item' => ['name' => 'Test'],
];
$result = $this->evaluator->evaluate("item['missing']", $context);
expect($result)->toBeNull();
});
it('handles invalid expressions gracefully', function () {
$context = [];
$result = $this->evaluator->evaluate('invalid[expression', $context);
expect($result)->toBeNull();
});
});
describe('dot notation', function () {
it('supports dot notation for nested properties', function () {
$context = [
'user' => [
'profile' => [
'name' => 'John',
],
],
];
$result = $this->evaluator->evaluate('user.profile.name', $context);
expect($result)->toBe('John');
});
it('supports dot notation with numeric indices', function () {
$context = [
'items' => [
['name' => 'First'],
['name' => 'Second'],
],
];
$result = $this->evaluator->evaluate('items.0.name', $context);
expect($result)->toBe('First');
});
it('supports dot notation with $ prefix for arrays', function () {
$context = [
'option' => [
'id' => 'landing_page',
'name' => 'Landing Page',
],
];
$result = $this->evaluator->evaluate('$option.id', $context);
expect($result)->toBe('landing_page');
});
it('supports dot notation with $ prefix for nested arrays', function () {
$context = [
'option' => [
'user' => [
'name' => 'John',
],
],
];
$result = $this->evaluator->evaluate('$option.user.name', $context);
expect($result)->toBe('John');
});
it('supports both array notation and dot notation', function () {
$context = [
'option' => [
'id' => 'test',
'name' => 'Test',
],
];
$arrayResult = $this->evaluator->evaluate("option['id']", $context);
$dotResult = $this->evaluator->evaluate('option.id', $context);
$dollarDotResult = $this->evaluator->evaluate('$option.id', $context);
expect($arrayResult)->toBe('test');
expect($dotResult)->toBe('test');
expect($dollarDotResult)->toBe('test');
});
});
});

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\View\Dom\Transformer;
use App\Framework\DI\DefaultContainer;
use App\Framework\View\Dom\Parser\HtmlParser;
use App\Framework\View\Dom\Renderer\HtmlRenderer;
use App\Framework\View\Dom\Transformer\PlaceholderTransformer;
use App\Framework\View\RenderContext;
use App\Framework\Meta\MetaData;
describe('PlaceholderTransformer', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
$this->parser = new HtmlParser();
$this->renderer = new HtmlRenderer();
$this->transformer = new PlaceholderTransformer($this->container);
});
describe('fallback mechanism', function () {
it('supports null coalescing operator', function () {
$html = '<div>{{ $name ?? "Default Name" }}</div>';
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: []
);
$document = $this->parser->parse($html);
$document = $this->transformer->transform($document, $context);
$output = $this->renderer->render($document);
expect($output)->toContain('Default Name');
});
it('uses actual value when variable exists', function () {
$html = '<div>{{ $name ?? "Default Name" }}</div>';
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: ['name' => 'Actual Name']
);
$document = $this->parser->parse($html);
$document = $this->transformer->transform($document, $context);
$output = $this->renderer->render($document);
expect($output)->toContain('Actual Name');
expect($output)->not->toContain('Default Name');
});
it('handles null values with fallback', function () {
$html = '<div>{{ $name ?? "Default" }}</div>';
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: ['name' => null]
);
$document = $this->parser->parse($html);
$document = $this->transformer->transform($document, $context);
$output = $this->renderer->render($document);
expect($output)->toContain('Default');
});
});
describe('nested array access', function () {
it('supports nested array access in placeholders', function () {
$html = '<div>{{ $item["user"]["name"] }}</div>';
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: [
'item' => [
'user' => [
'name' => 'John',
],
],
]
);
$document = $this->parser->parse($html);
$document = $this->transformer->transform($document, $context);
$output = $this->renderer->render($document);
expect($output)->toContain('John');
});
});
describe('error handling', function () {
it('returns empty string for missing variables in production', function () {
putenv('APP_DEBUG=false');
$html = '<div>{{ $missing }}</div>';
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: []
);
$document = $this->parser->parse($html);
$document = $this->transformer->transform($document, $context);
$output = $this->renderer->render($document);
// Should not throw, but return empty string
expect($output)->toContain('<div></div>');
});
});
});

View File

@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\View\Dom\Transformer;
use App\Framework\Config\AppConfig;
use App\Framework\DI\DefaultContainer;
use App\Framework\LiveComponents\ComponentRegistry;
use App\Framework\LiveComponents\Performance\ComponentMetadataCache;
use App\Framework\LiveComponents\Performance\ComponentMetadataCacheInterface;
use App\Framework\LiveComponents\Contracts\ComponentRegistryInterface;
use App\Framework\Meta\MetaData;
use App\Framework\View\Components\Button;
use App\Framework\View\Dom\Parser\HtmlParser;
use App\Framework\View\Dom\Renderer\HtmlRenderer;
use App\Framework\View\Dom\Transformer\ForTransformer;
use App\Framework\View\Dom\Transformer\PlaceholderTransformer;
use App\Framework\View\Dom\Transformer\XComponentTransformer;
use App\Framework\View\Processing\AstProcessingPipeline;
use App\Framework\View\RenderContext;
use App\Framework\View\StaticComponentRenderer;
describe('XComponentTransformer + ForTransformer Integration', function () {
beforeEach(function () {
$this->container = new DefaultContainer();
$this->parser = new HtmlParser();
$this->renderer = new HtmlRenderer();
// Create component registry and dependencies
$discoveryRegistry = $this->container->get('App\Framework\Discovery\Results\DiscoveryRegistry');
$liveComponentRenderer = $this->container->get('App\Framework\View\LiveComponentRenderer');
$cacheManager = $this->container->get('App\Framework\LiveComponents\Cache\ComponentCacheManager');
$handler = $this->container->get('App\Framework\LiveComponents\LiveComponentHandler');
$metadataCache = new ComponentMetadataCache($this->container);
$performanceTracker = $this->container->get('App\Framework\LiveComponents\Performance\NestedPerformanceTracker');
$this->componentRegistry = new ComponentRegistry(
$this->container,
$discoveryRegistry,
$liveComponentRenderer,
$cacheManager,
$handler,
$metadataCache,
$performanceTracker
);
$this->staticComponentRenderer = new StaticComponentRenderer();
$this->metadataCache = $metadataCache;
$this->appConfig = new AppConfig(['debug' => true]);
// Create transformers
$this->forTransformer = new ForTransformer($this->container);
$this->placeholderTransformer = new PlaceholderTransformer($this->container);
$this->xComponentTransformer = new XComponentTransformer(
$this->componentRegistry,
$this->staticComponentRenderer,
$this->metadataCache,
$this->parser,
$this->appConfig
);
});
it('transforms x-button components within for loops', function () {
$html = <<<HTML
<ul>
<for items="{{pages}}" as="page">
<li>
<x-button variant="secondary" size="sm" href="{{page.url}}" class="page-link">{{page.number}}</x-button>
</li>
</for>
</ul>
HTML;
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: [
'pages' => [
['number' => 1, 'url' => '/page/1'],
['number' => 2, 'url' => '/page/2'],
['number' => 3, 'url' => '/page/3'],
],
]
);
// Process through pipeline: ForTransformer -> PlaceholderTransformer -> XComponentTransformer
$document = $this->parser->parse($html);
$document = $this->forTransformer->transform($document, $context);
$document = $this->placeholderTransformer->transform($document, $context);
$document = $this->xComponentTransformer->transform($document, $context);
// Render to HTML
$output = $this->renderer->render($document);
// Assert: No x-button tags should remain
expect($output)->not->toContain('<x-button');
expect($output)->not->toContain('</x-button>');
// Assert: Buttons should be rendered as <a> or <button> elements
expect($output)->toContain('<a');
expect($output)->toContain('href="/page/1"');
expect($output)->toContain('href="/page/2"');
expect($output)->toContain('href="/page/3"');
expect($output)->toContain('1');
expect($output)->toContain('2');
expect($output)->toContain('3');
});
it('transforms x-button components in pagination-like structure', function () {
$html = <<<HTML
<nav>
<ul class="pagination">
<li if="{{has_previous}}">
<x-button variant="secondary" size="sm" href="{{previous_url}}" class="page-link">Previous</x-button>
</li>
<for items="{{pages}}" as="page">
<li if="{{!page.ellipsis}}">
<x-button variant="{{page.active ? 'primary' : 'secondary'}}" size="sm" href="{{page.url}}" class="page-link">{{page.number}}</x-button>
</li>
</for>
<li if="{{has_next}}">
<x-button variant="secondary" size="sm" href="{{next_url}}" class="page-link">Next</x-button>
</li>
</ul>
</nav>
HTML;
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: [
'has_previous' => true,
'has_next' => true,
'previous_url' => '/page/1',
'next_url' => '/page/3',
'pages' => [
['number' => 1, 'url' => '/page/1', 'active' => true, 'ellipsis' => false],
['number' => 2, 'url' => '/page/2', 'active' => false, 'ellipsis' => false],
['number' => 3, 'url' => '/page/3', 'active' => false, 'ellipsis' => false],
],
]
);
// Process through pipeline
$document = $this->parser->parse($html);
$document = $this->forTransformer->transform($document, $context);
$document = $this->placeholderTransformer->transform($document, $context);
$document = $this->xComponentTransformer->transform($document, $context);
// Render to HTML
$output = $this->renderer->render($document);
// Assert: No x-button tags should remain
expect($output)->not->toContain('<x-button');
expect($output)->not->toContain('</x-button>');
// Assert: All buttons should be rendered
expect($output)->toContain('Previous');
expect($output)->toContain('Next');
expect($output)->toContain('href="/page/1"');
expect($output)->toContain('href="/page/2"');
expect($output)->toContain('href="/page/3"');
});
it('handles nested for loops with x-button components', function () {
$html = <<<HTML
<div>
<for items="{{sections}}" as="section">
<h2>{{section.title}}</h2>
<ul>
<for items="{{section.items}}" as="item">
<li>
<x-button variant="secondary" href="{{item.url}}">{{item.name}}</x-button>
</li>
</for>
</ul>
</for>
</div>
HTML;
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: [
'sections' => [
[
'title' => 'Section 1',
'items' => [
['name' => 'Item 1', 'url' => '/item/1'],
['name' => 'Item 2', 'url' => '/item/2'],
],
],
[
'title' => 'Section 2',
'items' => [
['name' => 'Item 3', 'url' => '/item/3'],
],
],
],
]
);
// Process through pipeline
$document = $this->parser->parse($html);
$document = $this->forTransformer->transform($document, $context);
$document = $this->placeholderTransformer->transform($document, $context);
$document = $this->xComponentTransformer->transform($document, $context);
// Render to HTML
$output = $this->renderer->render($document);
// Assert: No x-button tags should remain
expect($output)->not->toContain('<x-button');
expect($output)->not->toContain('</x-button>');
// Assert: All buttons should be rendered
expect($output)->toContain('Item 1');
expect($output)->toContain('Item 2');
expect($output)->toContain('Item 3');
expect($output)->toContain('href="/item/1"');
expect($output)->toContain('href="/item/2"');
expect($output)->toContain('href="/item/3"');
});
it('handles empty for loops gracefully', function () {
$html = <<<HTML
<ul>
<for items="{{pages}}" as="page">
<li>
<x-button variant="secondary" href="{{page.url}}">{{page.number}}</x-button>
</li>
</for>
</ul>
HTML;
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: [
'pages' => [],
]
);
// Process through pipeline
$document = $this->parser->parse($html);
$document = $this->forTransformer->transform($document, $context);
$document = $this->placeholderTransformer->transform($document, $context);
$document = $this->xComponentTransformer->transform($document, $context);
// Render to HTML
$output = $this->renderer->render($document);
// Assert: No x-button tags should remain
expect($output)->not->toContain('<x-button');
expect($output)->not->toContain('</x-button>');
expect($output)->not->toContain('<for');
});
it('processes x-button components outside for loops correctly', function () {
$html = <<<HTML
<div>
<x-button variant="primary" href="/outside">Outside Button</x-button>
<ul>
<for items="{{pages}}" as="page">
<li>
<x-button variant="secondary" href="{{page.url}}">{{page.number}}</x-button>
</li>
</for>
</ul>
<x-button variant="primary" href="/after">After Button</x-button>
</div>
HTML;
$context = new RenderContext(
template: 'test',
metaData: new MetaData('Test'),
data: [
'pages' => [
['number' => 1, 'url' => '/page/1'],
],
]
);
// Process through pipeline
$document = $this->parser->parse($html);
$document = $this->forTransformer->transform($document, $context);
$document = $this->placeholderTransformer->transform($document, $context);
$document = $this->xComponentTransformer->transform($document, $context);
// Render to HTML
$output = $this->renderer->render($document);
// Assert: No x-button tags should remain
expect($output)->not->toContain('<x-button');
expect($output)->not->toContain('</x-button>');
// Assert: All buttons should be rendered
expect($output)->toContain('Outside Button');
expect($output)->toContain('After Button');
expect($output)->toContain('href="/outside"');
expect($output)->toContain('href="/after"');
expect($output)->toContain('href="/page/1"');
});
});

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
use App\Framework\Http\Session\FormIdGenerator;
use App\Framework\Http\Session\SessionId;
use App\Framework\Http\Session\SessionInterface;
use App\Framework\Http\Session\SessionManager;
use App\Framework\View\Response\FormDataResponseProcessor;
beforeEach(function () {
$this->formIdGenerator = new FormIdGenerator();
$this->sessionManager = Mockery::mock(SessionManager::class);
$this->processor = new FormDataResponseProcessor(
$this->formIdGenerator,
$this->sessionManager
);
// Mock session
$this->session = Mockery::mock(SessionInterface::class);
$this->csrfProtection = Mockery::mock();
$this->session->shouldReceive('csrf')->andReturn($this->csrfProtection);
$this->sessionManager->shouldReceive('saveSessionData')->andReturnNull();
});
it('replaces token placeholder with DOM processing', function () {
$formId = 'form_abc123def456';
$token = str_repeat('a', 64);
$html = <<<HTML
<form>
<input type="hidden" name="_form_id" value="{$formId}">
<input type="hidden" name="_token" value="___TOKEN_{$formId}___">
</form>
HTML;
$this->csrfProtection->shouldReceive('generateToken')
->with($formId)
->once()
->andReturn(\App\Framework\Security\CsrfToken::fromString($token));
$result = $this->processor->process($html, $this->session);
expect($result)->toContain($token);
expect($result)->not->toContain("___TOKEN_{$formId}___");
});
it('handles token placeholder without quotes', function () {
$formId = 'form_abc123def456';
$token = str_repeat('b', 64);
$html = <<<HTML
<form>
<input type="hidden" name="_form_id" value="{$formId}">
<input type="hidden" name="_token" value=___TOKEN_{$formId}___>
</form>
HTML;
$this->csrfProtection->shouldReceive('generateToken')
->with($formId)
->once()
->andReturn(\App\Framework\Security\CsrfToken::fromString($token));
$result = $this->processor->process($html, $this->session);
expect($result)->toContain('value="' . $token . '"');
expect($result)->not->toContain("___TOKEN_{$formId}___");
});
it('falls back to regex when DOM processing fails', function () {
$formId = 'form_abc123def456';
$token = str_repeat('c', 64);
// Malformed HTML that might cause DOM parsing issues
$html = <<<HTML
<form>
<input type="hidden" name="_form_id" value="{$formId}">
<input type="hidden" name="_token" value="___TOKEN_{$formId}___">
<unclosed-tag>
</form>
HTML;
$this->csrfProtection->shouldReceive('generateToken')
->with($formId)
->once()
->andReturn(\App\Framework\Security\CsrfToken::fromString($token));
// Should not throw exception, should fall back to regex
$result = $this->processor->process($html, $this->session);
// Should still replace token (via regex fallback)
expect($result)->toContain($token);
});
it('processes multiple forms independently', function () {
$formId1 = 'form_abc123def456';
$formId2 = 'form_xyz789ghi012';
$token1 = str_repeat('d', 64);
$token2 = str_repeat('e', 64);
$html = <<<HTML
<form>
<input type="hidden" name="_form_id" value="{$formId1}">
<input type="hidden" name="_token" value="___TOKEN_{$formId1}___">
</form>
<form>
<input type="hidden" name="_form_id" value="{$formId2}">
<input type="hidden" name="_token" value="___TOKEN_{$formId2}___">
</form>
HTML;
$this->csrfProtection->shouldReceive('generateToken')
->with($formId1)
->once()
->andReturn(\App\Framework\Security\CsrfToken::fromString($token1));
$this->csrfProtection->shouldReceive('generateToken')
->with($formId2)
->once()
->andReturn(\App\Framework\Security\CsrfToken::fromString($token2));
$result = $this->processor->process($html, $this->session);
expect($result)->toContain($token1);
expect($result)->toContain($token2);
expect($result)->not->toContain("___TOKEN_{$formId1}___");
expect($result)->not->toContain("___TOKEN_{$formId2}___");
});
it('validates token length after replacement', function () {
$formId = 'form_abc123def456';
$token = str_repeat('f', 64);
$html = <<<HTML
<form>
<input type="hidden" name="_form_id" value="{$formId}">
<input type="hidden" name="_token" value="___TOKEN_{$formId}___">
</form>
HTML;
$this->csrfProtection->shouldReceive('generateToken')
->with($formId)
->once()
->andReturn(\App\Framework\Security\CsrfToken::fromString($token));
$result = $this->processor->process($html, $this->session);
// Extract token from result
preg_match('/name="_token"[^>]*value="([^"]+)"/', $result, $matches);
if (isset($matches[1])) {
expect(strlen($matches[1]))->toBe(64);
}
});