feat(Production): Complete production deployment infrastructure

- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
This commit is contained in:
2025-10-25 19:18:37 +02:00
parent caa85db796
commit fc3d7e6357
83016 changed files with 378904 additions and 20919 deletions

View File

@@ -0,0 +1,530 @@
<?php
declare(strict_types=1);
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheResult;
use App\Framework\Core\PathProvider;
use App\Framework\View\Caching\Analysis\CacheStrategy;
use App\Framework\View\Caching\Analysis\SmartTemplateAnalyzer;
use App\Framework\View\Loading\TemplateLoader;
describe('SmartTemplateAnalyzer', function () {
beforeEach(function () {
// Create test templates directory
$this->testDir = '/home/michael/dev/michaelschiemer/tests/tmp/smart_analyzer_' . uniqid();
if (!is_dir($this->testDir)) {
mkdir($this->testDir, 0755, true);
}
// Create real PathProvider
$this->pathProvider = new PathProvider($this->testDir);
// Create mocked Cache
$this->cache = Mockery::mock(Cache::class);
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems(CacheItem::miss(CacheKey::fromString('test'))));
$this->cache->shouldReceive('set')->andReturn(true);
$this->cache->shouldReceive('clear')->andReturn(true);
// Create TemplateLoader with test directory as template path
$this->loader = new TemplateLoader(
pathProvider: $this->pathProvider,
cache: $this->cache,
templates: [],
templatePath: '', // Empty to use base path directly
cacheEnabled: false
);
$this->analyzer = new SmartTemplateAnalyzer($this->loader);
});
afterEach(function () {
Mockery::close();
// Clean up test files
if (is_dir($this->testDir)) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->testDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
$file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath());
}
rmdir($this->testDir);
}
});
describe('analyze()', function () {
it('recommends FULL_PAGE strategy for layouts with high static ratio', function () {
$content = '<html><head><title>Site</title></head><body>Mostly static content here</body></html>';
// Create template file
$filePath = $this->testDir . '/layouts/main.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('layouts/main');
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::FULL_PAGE);
expect($analysis->cacheability->staticContentRatio)->toBeGreaterThan(0.8);
});
it('recommends COMPONENT strategy for component templates', function () {
$content = '<button class="btn">{{ text }}</button>';
// Create template file
$filePath = $this->testDir . '/components/button.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('components/button');
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::COMPONENT);
});
it('recommends COMPONENT strategy for partial templates', function () {
$content = '<nav class="main-navigation"><ul class="nav-list">{{ navigation_items }}</ul></nav>';
// Create template file
$filePath = $this->testDir . '/partials/navigation.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('partials/navigation');
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::COMPONENT);
});
it('recommends NO_CACHE for templates with user content', function () {
$content = '<div>Welcome {{ user.name }}! <p>Your profile</p></div>';
// Create template file
$filePath = $this->testDir . '/pages/profile.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('pages/profile');
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::NO_CACHE);
expect($analysis->cacheability->hasUserSpecificContent)->toBeTrue();
});
it('detects CSRF tokens and adjusts cacheability score', function () {
$content = '<form method="POST"><input type="hidden" name="_token" value="{{ csrf_token }}"></form>';
// Create template file
$filePath = $this->testDir . '/forms/contact.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('forms/contact');
// CSRF tokens detected but high static ratio (0.839) means still cacheable (0.839 - 0.2 = 0.639 > 0.5)
expect($analysis->cacheability->hasCsrfTokens)->toBeTrue();
expect($analysis->cacheability->getScore())->toBeGreaterThan(0.5);
});
it('detects timestamps and adjusts cacheability score', function () {
$content = '<div>Current time: {{ now }}</div>';
// Create template file
$filePath = $this->testDir . '/widgets/clock.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('widgets/clock');
// Timestamps detected but high static ratio means still cacheable
expect($analysis->cacheability->hasTimestamps)->toBeTrue();
expect($analysis->cacheability->getScore())->toBeGreaterThan(0.5);
});
it('recommends NO_CACHE for templates with random elements', function () {
$content = '<div>Random ID: {{ uuid }}</div>';
// Create template file
$filePath = $this->testDir . '/widgets/random.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('widgets/random');
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::NO_CACHE);
expect($analysis->cacheability->hasRandomElements)->toBeTrue();
});
it('recommends FRAGMENT strategy for medium cacheability templates', function () {
$content = '<div class="content"><h1>Title</h1><p>Some content</p><span>{{ small_dynamic_part }}</span></div>';
// Create template file
$filePath = $this->testDir . '/pages/mixed.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('pages/mixed');
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::FRAGMENT);
expect($analysis->cacheability->getScore())->toBeGreaterThan(0.5);
});
it('creates fallback analysis when loader throws exception', function () {
$analysis = $this->analyzer->analyze('missing/template');
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::NO_CACHE);
expect($analysis->recommendedTtl)->toBe(0);
expect($analysis->dependencies)->toBeEmpty();
});
it('adjusts TTL based on cacheability score', function () {
$staticContent = '<html><body>All static content here</body></html>';
// Create template file
$filePath = $this->testDir . '/static/page.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $staticContent);
$analysis = $this->analyzer->analyze('static/page');
expect($analysis->recommendedTtl)->toBeGreaterThan(0);
});
});
describe('getDependencies()', function () {
it('finds @include directives', function () {
$content = '@include("header") <div>Content</div> @include("footer")';
// Create template file
$filePath = $this->testDir . '/page/test.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$deps = $this->analyzer->getDependencies('page/test');
expect($deps)->toHaveKey('includes');
expect($deps['includes'])->toContain('header');
expect($deps['includes'])->toContain('footer');
});
it('finds <component> tags', function () {
$content = '<component name="button" /> <component name="card" />';
// Create template file
$filePath = $this->testDir . '/page/test2.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$deps = $this->analyzer->getDependencies('page/test2');
expect($deps)->toHaveKey('components');
expect($deps['components'])->toContain('button');
expect($deps['components'])->toContain('card');
});
it('finds both includes and components', function () {
$content = '@include("layout") <component name="nav" /> <component name="footer" />';
// Create template file
$filePath = $this->testDir . '/page/test3.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$deps = $this->analyzer->getDependencies('page/test3');
expect($deps)->toHaveKey('includes');
expect($deps)->toHaveKey('components');
expect($deps['includes'])->toContain('layout');
expect($deps['components'])->toContain('nav');
});
it('returns empty array when no dependencies found', function () {
$content = '<div>No dependencies here</div>';
// Create template file
$filePath = $this->testDir . '/page/simple.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$deps = $this->analyzer->getDependencies('page/simple');
expect($deps)->toBeEmpty();
});
it('returns empty array when loader throws exception', function () {
$deps = $this->analyzer->getDependencies('page/error');
expect($deps)->toBeEmpty();
});
});
describe('getCacheability()', function () {
it('detects user-specific content patterns', function () {
$content = '{{ user.name }} {{ current_user.email }} {{ auth.role }}';
// Create template file
$filePath = $this->testDir . '/test/user.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$score = $this->analyzer->getCacheability('test/user');
expect($score->hasUserSpecificContent)->toBeTrue();
expect($score->isCacheable())->toBeFalse();
});
it('detects CSRF token patterns', function () {
$content = '{{ csrf_token }} {{ _token }}';
// Create template file
$filePath = $this->testDir . '/test/csrf.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$score = $this->analyzer->getCacheability('test/csrf');
expect($score->hasCsrfTokens)->toBeTrue();
expect($score->isCacheable())->toBeFalse();
});
it('detects timestamp patterns', function () {
$content = '{{ now }} {{ timestamp }} {{ date() }}';
// Create template file
$filePath = $this->testDir . '/test/time.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$score = $this->analyzer->getCacheability('test/time');
expect($score->hasTimestamps)->toBeTrue();
expect($score->isCacheable())->toBeFalse();
});
it('detects random element patterns', function () {
$content = '{{ random }} {{ rand() }} {{ uuid }}';
// Create template file
$filePath = $this->testDir . '/test/random.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$score = $this->analyzer->getCacheability('test/random');
expect($score->hasRandomElements)->toBeTrue();
expect($score->isCacheable())->toBeFalse();
});
it('calculates high static content ratio for mostly static templates', function () {
$content = '<div>Static content</div><p>More static content here with lots of text</p><section>Even more static HTML content</section>{{ small_dynamic }}';
// Create template file
$filePath = $this->testDir . '/test/static.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$score = $this->analyzer->getCacheability('test/static');
expect($score->staticContentRatio)->toBeGreaterThan(0.8);
expect($score->isCacheable())->toBeTrue();
});
it('calculates low static content ratio for mostly dynamic templates', function () {
$content = '{{ var1 }} {{ var2 }} {{ var3 }} {{ var4 }} <p>static</p>';
// Create template file
$filePath = $this->testDir . '/test/dynamic.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$score = $this->analyzer->getCacheability('test/dynamic');
expect($score->staticContentRatio)->toBeLessThan(0.5);
});
it('returns default score when loader throws exception', function () {
$score = $this->analyzer->getCacheability('test/error');
// Default CacheabilityScore has staticContentRatio = 0.0
expect($score->staticContentRatio)->toBe(0.0);
expect($score->hasUserSpecificContent)->toBeFalse();
expect($score->hasCsrfTokens)->toBeFalse();
expect($score->hasTimestamps)->toBeFalse();
expect($score->hasRandomElements)->toBeFalse();
});
it('handles empty content gracefully', function () {
// Create empty template file
$filePath = $this->testDir . '/test/empty.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, '');
$score = $this->analyzer->getCacheability('test/empty');
expect($score->staticContentRatio)->toBe(0.0);
});
});
describe('integration scenarios', function () {
it('correctly analyzes a typical blog post template', function () {
$content = <<<HTML
<article class="post">
<header>
<h1>{{ post.title }}</h1>
<time>{{ post.published_at }}</time>
</header>
<div class="content">
{{ post.content }}
</div>
<footer>
<p>Read more articles</p>
</footer>
</article>
HTML;
// Create template file
$filePath = $this->testDir . '/blog/post.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('blog/post');
expect($analysis->cacheability->hasTimestamps)->toBeFalse();
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::FRAGMENT);
});
it('correctly analyzes a static landing page', function () {
$content = <<<HTML
<html>
<head><title>Welcome</title></head>
<body>
<header><h1>Welcome to our site</h1></header>
<main>
<section>Static marketing content</section>
<section>More static content</section>
</main>
<footer>Copyright 2024</footer>
</body>
</html>
HTML;
// Create template file
$filePath = $this->testDir . '/pages/landing.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('pages/landing');
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::FULL_PAGE);
expect($analysis->cacheability->staticContentRatio)->toBeGreaterThan(0.9);
});
it('correctly analyzes a reusable button component', function () {
$content = '<button class="btn btn-{{ variant }}">{{ text }}</button>';
// Create template file
$filePath = $this->testDir . '/components/button2.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('components/button2');
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::COMPONENT);
expect($analysis->recommendedTtl)->toBeGreaterThan(0);
});
it('correctly analyzes a form with CSRF protection', function () {
$content = <<<HTML
<form method="POST" action="/submit">
{{ csrf_token }}
<input type="text" name="email" />
<button type="submit">Submit</button>
</form>
HTML;
// Create template file
$filePath = $this->testDir . '/forms/contact2.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
file_put_contents($filePath, $content);
$analysis = $this->analyzer->analyze('forms/contact2');
// CSRF detected and high static ratio means still cacheable, recommends FULL_PAGE
expect($analysis->cacheability->hasCsrfTokens)->toBeTrue();
expect($analysis->cacheability->staticContentRatio)->toBeGreaterThan(0.8);
expect($analysis->recommendedStrategy)->toBe(CacheStrategy::FULL_PAGE);
});
});
});

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheResult;
use App\Framework\View\Caching\Analysis\CacheabilityScore;
use App\Framework\View\Caching\Analysis\CacheStrategy;
use App\Framework\View\Caching\Analysis\TemplateAnalysis;
use App\Framework\View\Caching\Analysis\TemplateAnalyzer;
use App\Framework\View\Caching\CacheManager;
use App\Framework\View\Caching\FragmentCache;
use App\Framework\View\Caching\TemplateContext;
// Helper function to create TemplateAnalysis
function createAnalysis(
string $template,
CacheStrategy $strategy,
float $staticRatio = 1.0,
bool $hasUserContent = false,
bool $hasCsrf = false,
bool $hasTimestamps = false,
bool $hasRandom = false
): TemplateAnalysis {
$cacheability = new CacheabilityScore();
$cacheability->staticContentRatio = $staticRatio;
$cacheability->hasUserSpecificContent = $hasUserContent;
$cacheability->hasCsrfTokens = $hasCsrf;
$cacheability->hasTimestamps = $hasTimestamps;
$cacheability->hasRandomElements = $hasRandom;
return new TemplateAnalysis(
template: $template,
recommendedStrategy: $strategy,
recommendedTtl: $strategy->getTtl(),
dependencies: [],
cacheability: $cacheability,
fragments: []
);
}
describe('CacheManager', function () {
beforeEach(function () {
$this->cache = Mockery::mock(Cache::class);
$this->analyzer = Mockery::mock(TemplateAnalyzer::class);
$this->fragmentCache = Mockery::mock(FragmentCache::class);
$this->cacheManager = new CacheManager(
$this->cache,
$this->analyzer,
$this->fragmentCache
);
});
afterEach(function () {
Mockery::close();
});
describe('render() with cacheable strategies', function () {
it('renders and caches on cache miss for FULL_PAGE strategy', function () {
$context = new TemplateContext(
template: 'pages/contact',
data: ['email' => 'contact@example.com']
);
$analysis = createAnalysis(
'pages/contact',
CacheStrategy::FULL_PAGE,
staticRatio: 0.9
);
$this->analyzer->shouldReceive('analyze')
->once()
->with('pages/contact')
->andReturn($analysis);
// Create actual CacheResult with cache miss
$cacheKey = CacheKey::fromString('page:pages/contact:' . md5(serialize(['email' => 'contact@example.com'])));
$missItem = CacheItem::miss($cacheKey);
$cacheResult = CacheResult::fromItems($missItem);
$this->cache->shouldReceive('get')
->once()
->andReturn($cacheResult);
// Expect cache set with rendered content
$renderedContent = '<html>Contact Page</html>';
$this->cache->shouldReceive('set')
->once()
->with(Mockery::type(CacheItem::class))
->andReturn(true);
$result = $this->cacheManager->render($context, function () use ($renderedContent) {
return $renderedContent;
});
expect($result)->toBe($renderedContent);
});
it('caches component templates with COMPONENT strategy', function () {
$context = new TemplateContext(
template: 'components/button',
data: ['text' => 'Click me']
);
$analysis = createAnalysis(
'components/button',
CacheStrategy::COMPONENT,
staticRatio: 1.0
);
$this->analyzer->shouldReceive('analyze')
->once()
->andReturn($analysis);
// Create actual CacheResult with cache miss
$cacheKey = CacheKey::fromString('component:button:' . md5(serialize(['text' => 'Click me'])));
$missItem = CacheItem::miss($cacheKey);
$cacheResult = CacheResult::fromItems($missItem);
$this->cache->shouldReceive('get')->once()->andReturn($cacheResult);
$this->cache->shouldReceive('set')->once()->andReturn(true);
$renderedContent = '<button>Click me</button>';
$result = $this->cacheManager->render($context, fn() => $renderedContent);
expect($result)->toBe($renderedContent);
});
it('caches fragment with FRAGMENT strategy', function () {
$context = new TemplateContext(
template: 'pages/dashboard',
data: ['stats' => []],
metadata: ['fragment_id' => 'user-stats']
);
$analysis = createAnalysis(
'pages/dashboard',
CacheStrategy::FRAGMENT,
staticRatio: 0.6
);
$this->analyzer->shouldReceive('analyze')
->once()
->andReturn($analysis);
// Create actual CacheResult with cache miss
$cacheKey = CacheKey::fromString('fragment:pages/dashboard:user-stats:' . md5(serialize(['stats' => []])));
$missItem = CacheItem::miss($cacheKey);
$cacheResult = CacheResult::fromItems($missItem);
$this->cache->shouldReceive('get')->once()->andReturn($cacheResult);
$this->cache->shouldReceive('set')->once()->andReturn(true);
$renderedContent = '<div class="stats">Stats Fragment</div>';
$result = $this->cacheManager->render($context, fn() => $renderedContent);
expect($result)->toBe($renderedContent);
});
});
describe('render() with NO_CACHE strategy', function () {
it('skips caching for non-cacheable content', function () {
$context = new TemplateContext(
template: 'pages/dynamic',
data: ['user' => ['id' => 1], 'timestamp' => time()]
);
$analysis = createAnalysis(
'pages/dynamic',
CacheStrategy::NO_CACHE,
staticRatio: 0.2,
hasUserContent: true,
hasTimestamps: true
);
$this->analyzer->shouldReceive('analyze')
->once()
->andReturn($analysis);
// No cache operations expected
$this->cache->shouldReceive('get')->never();
$this->cache->shouldReceive('set')->never();
$renderedContent = '<html>Dynamic Content</html>';
$result = $this->cacheManager->render($context, fn() => $renderedContent);
expect($result)->toBe($renderedContent);
});
});
describe('invalidateTemplate()', function () {
it('invalidates cache for given template', function () {
// clear() is called once per strategy that canInvalidate
$this->cache->shouldReceive('clear')
->atLeast()->once()
->andReturn(true);
$invalidated = $this->cacheManager->invalidateTemplate('pages/about');
// Returns count of invalidated caches
expect($invalidated)->toBeGreaterThanOrEqual(0);
});
it('builds correct invalidation patterns for different strategies', function () {
// This test verifies the pattern building logic by invalidating
$this->cache->shouldReceive('clear')
->atLeast()->once()
->andReturn(true);
// Invalidate various template types
$this->cacheManager->invalidateTemplate('pages/home');
$this->cacheManager->invalidateTemplate('components/button');
$this->cacheManager->invalidateTemplate('fragments/stats');
expect(true)->toBeTrue(); // Verify no exceptions thrown
});
});
describe('edge cases', function () {
it('handles empty template content', function () {
$context = new TemplateContext(
template: 'pages/empty',
data: []
);
$analysis = createAnalysis(
'pages/empty',
CacheStrategy::NO_CACHE
);
$this->analyzer->shouldReceive('analyze')
->once()
->andReturn($analysis);
$result = $this->cacheManager->render($context, fn() => '');
expect($result)->toBe('');
});
it('handles large rendered content', function () {
$context = new TemplateContext(
template: 'pages/large',
data: []
);
$analysis = createAnalysis(
'pages/large',
CacheStrategy::NO_CACHE
);
$this->analyzer->shouldReceive('analyze')
->once()
->andReturn($analysis);
$largeContent = str_repeat('<p>Content</p>', 1000);
$result = $this->cacheManager->render($context, fn() => $largeContent);
expect($result)->toBe($largeContent);
expect(strlen($result))->toBeGreaterThan(10000);
});
});
});

View File

@@ -0,0 +1,412 @@
<?php
declare(strict_types=1);
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheItem;
use App\Framework\Cache\CacheKey;
use App\Framework\Cache\CacheResult;
use App\Framework\View\Caching\Analysis\CacheStrategy;
use App\Framework\View\Caching\Analysis\SmartTemplateAnalyzer;
use App\Framework\View\Caching\CacheManager;
use App\Framework\View\Caching\FragmentCache;
use App\Framework\View\Caching\TemplateContext;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\Core\PathProvider;
describe('Caching Performance Benchmarks', function () {
beforeEach(function () {
// Create test templates directory
$this->testDir = '/home/michael/dev/michaelschiemer/tests/tmp/caching_perf_' . uniqid();
if (!is_dir($this->testDir)) {
mkdir($this->testDir, 0755, true);
}
// Create PathProvider
$this->pathProvider = new PathProvider($this->testDir);
// Create Cache mock
$this->cache = Mockery::mock(Cache::class);
// Create TemplateLoader
$this->loader = new TemplateLoader(
pathProvider: $this->pathProvider,
cache: $this->cache,
templates: [],
templatePath: '',
cacheEnabled: true
);
// Create Analyzer
$this->analyzer = new SmartTemplateAnalyzer($this->loader);
// Create FragmentCache mock
$this->fragmentCache = Mockery::mock(FragmentCache::class);
// Create CacheManager
$this->cacheManager = new CacheManager(
$this->cache,
$this->analyzer,
$this->fragmentCache
);
// Store results for reporting
$this->perfResults = [];
});
afterEach(function () {
// Cleanup test directory
if (is_dir($this->testDir)) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->testDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($files as $file) {
$file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath());
}
rmdir($this->testDir);
}
Mockery::close();
// Print performance summary
if (!empty($this->perfResults)) {
echo "\n\n=== Performance Summary ===\n";
foreach ($this->perfResults as $test => $results) {
echo "\n{$test}:\n";
// Check if this is timing stats or other metrics
if (isset($results['min'])) {
echo " Min: " . number_format($results['min'], 2) . " ms\n";
echo " Max: " . number_format($results['max'], 2) . " ms\n";
echo " Avg: " . number_format($results['avg'], 2) . " ms\n";
echo " Median: " . number_format($results['median'], 2) . " ms\n";
echo " P95: " . number_format($results['p95'], 2) . " ms\n";
echo " P99: " . number_format($results['p99'], 2) . " ms\n";
}
if (isset($results['memory'])) {
echo " Memory: " . number_format($results['memory'], 2) . " MB\n";
}
if (isset($results['throughput_per_sec'])) {
echo " Throughput: " . number_format($results['throughput_per_sec'], 0) . " renders/sec\n";
}
}
echo "\n";
}
});
describe('Cache Hit vs Miss Performance', function () {
it('benchmarks cache hit performance (FULL_PAGE strategy)', function () {
// Create large static template
$content = '<html><head><title>Static Page</title></head><body>' . str_repeat('<p>Static content paragraph.</p>', 100) . '</body></html>';
$filePath = $this->testDir . '/pages/static.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($filePath, $content);
$context = new TemplateContext(
template: 'pages/static',
data: ['title' => 'Test']
);
$renderedContent = $content;
$measurements = [];
// Warm up
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems(CacheItem::miss(CacheKey::fromString('test'))));
$this->cache->shouldReceive('set')->andReturn(true);
$this->cacheManager->render($context, fn() => $renderedContent);
// Measure cache miss (10 iterations)
$missMeasurements = [];
for ($i = 0; $i < 10; $i++) {
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems(CacheItem::miss(CacheKey::fromString("test_{$i}"))));
$this->cache->shouldReceive('set')->andReturn(true);
$start = microtime(true);
$this->cacheManager->render($context, fn() => $renderedContent);
$missMeasurements[] = (microtime(true) - $start) * 1000;
}
// Measure cache hit (100 iterations)
$hitMeasurements = [];
$cacheKey = CacheKey::fromString('page:pages/static:' . md5(serialize(['title' => 'Test'])));
$cacheItem = CacheItem::hit($cacheKey, $renderedContent);
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems($cacheItem));
for ($i = 0; $i < 100; $i++) {
$start = microtime(true);
$this->cacheManager->render($context, fn() => $renderedContent);
$hitMeasurements[] = (microtime(true) - $start) * 1000;
}
// Calculate statistics
$missStats = calculateStats($missMeasurements);
$hitStats = calculateStats($hitMeasurements);
$this->perfResults['FULL_PAGE Cache Miss'] = $missStats;
$this->perfResults['FULL_PAGE Cache Hit'] = $hitStats;
// Assert performance characteristics (info-gathering, not strict benchmarks with mocks)
// Note: Mocked cache doesn't show real performance gains, but tests the workflow
expect($hitStats['avg'])->toBeLessThan(10.0); // Should complete in reasonable time
expect($missStats['avg'])->toBeLessThan(10.0); // Should complete in reasonable time
});
it('benchmarks cache hit performance (COMPONENT strategy)', function () {
// Create component template
$content = '<button class="btn btn-primary">' . str_repeat('Click me ', 10) . '</button>';
$filePath = $this->testDir . '/components/button.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($filePath, $content);
$context = new TemplateContext(
template: 'components/button',
data: ['text' => 'Submit']
);
$renderedContent = $content;
// Measure cache miss (10 iterations)
$missMeasurements = [];
for ($i = 0; $i < 10; $i++) {
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems(CacheItem::miss(CacheKey::fromString("test_{$i}"))));
$this->cache->shouldReceive('set')->andReturn(true);
$start = microtime(true);
$this->cacheManager->render($context, fn() => $renderedContent);
$missMeasurements[] = (microtime(true) - $start) * 1000;
}
// Measure cache hit (100 iterations)
$hitMeasurements = [];
$cacheKey = CacheKey::fromString('component:button:' . md5(serialize(['text' => 'Submit'])));
$cacheItem = CacheItem::hit($cacheKey, $renderedContent);
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems($cacheItem));
for ($i = 0; $i < 100; $i++) {
$start = microtime(true);
$this->cacheManager->render($context, fn() => $renderedContent);
$hitMeasurements[] = (microtime(true) - $start) * 1000;
}
$missStats = calculateStats($missMeasurements);
$hitStats = calculateStats($hitMeasurements);
$this->perfResults['COMPONENT Cache Miss'] = $missStats;
$this->perfResults['COMPONENT Cache Hit'] = $hitStats;
// Info-gathering: verify workflow completes in reasonable time
expect($hitStats['avg'])->toBeLessThan(10.0);
expect($missStats['avg'])->toBeLessThan(10.0);
});
});
describe('SmartTemplateAnalyzer Performance', function () {
it('benchmarks template analysis performance', function () {
// Create various templates
$templates = [
'pages/simple.view.php' => '<html><body>Simple static page</body></html>',
'pages/dynamic.view.php' => '<html><body>Hello {{ user.name }}, time: {{ now }}</body></html>',
'components/card.view.php' => '<div class="card"><h3>{{ title }}</h3><p>{{ content }}</p></div>',
'partials/nav.view.php' => '<nav><ul>' . str_repeat('<li>{{ item }}</li>', 5) . '</ul></nav>',
];
foreach ($templates as $path => $content) {
$filePath = $this->testDir . '/' . $path;
$dir = dirname($filePath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($filePath, $content);
}
// Measure analysis performance
$measurements = [];
foreach ($templates as $path => $content) {
$templateName = str_replace('.view.php', '', $path);
$start = microtime(true);
$analysis = $this->analyzer->analyze($templateName);
$measurements[] = (microtime(true) - $start) * 1000;
}
// Repeat for statistical significance (25 iterations per template)
for ($i = 0; $i < 24; $i++) {
foreach ($templates as $path => $content) {
$templateName = str_replace('.view.php', '', $path);
$start = microtime(true);
$this->analyzer->analyze($templateName);
$measurements[] = (microtime(true) - $start) * 1000;
}
}
$stats = calculateStats($measurements);
$this->perfResults['Template Analysis'] = $stats;
// Analysis should be fast (< 5ms average)
expect($stats['avg'])->toBeLessThan(5.0);
expect($stats['p99'])->toBeLessThan(10.0);
});
});
describe('Memory Usage Benchmarks', function () {
it('benchmarks memory usage across caching strategies', function () {
$memoryBefore = memory_get_usage(true);
// Create large templates
$largeContent = str_repeat('<div class="item">Static content item</div>', 1000);
// FULL_PAGE template
$filePath = $this->testDir . '/pages/large.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($filePath, $largeContent);
$context = new TemplateContext(template: 'pages/large', data: []);
// Mock cache responses
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems(CacheItem::miss(CacheKey::fromString('test'))));
$this->cache->shouldReceive('set')->andReturn(true);
// Render 100 times
for ($i = 0; $i < 100; $i++) {
$this->cacheManager->render($context, fn() => $largeContent);
}
$memoryAfter = memory_get_usage(true);
$memoryUsed = ($memoryAfter - $memoryBefore) / 1024 / 1024; // MB
$this->perfResults['Memory Usage (100 renders)']['memory'] = $memoryUsed;
// Memory usage should be reasonable (< 50MB for 100 renders with test overhead)
expect($memoryUsed)->toBeLessThan(50.0);
});
});
describe('Throughput Benchmarks', function () {
it('benchmarks rendering throughput with caching', function () {
// Create template
$content = '<html><body>' . str_repeat('<p>Paragraph content</p>', 50) . '</body></html>';
$filePath = $this->testDir . '/pages/throughput.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($filePath, $content);
$context = new TemplateContext(template: 'pages/throughput', data: []);
// Simulate cache hits
$cacheKey = CacheKey::fromString('page:pages/throughput:' . md5(serialize([])));
$cacheItem = CacheItem::hit($cacheKey, $content);
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems($cacheItem));
// Measure throughput
$iterations = 1000;
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$this->cacheManager->render($context, fn() => $content);
}
$duration = microtime(true) - $start;
$throughput = $iterations / $duration;
$this->perfResults['Throughput (cached)']['throughput_per_sec'] = $throughput;
echo "\nThroughput: " . number_format($throughput, 0) . " renders/sec\n";
// Info-gathering: verify reasonable throughput (> 500 renders/sec)
expect($throughput)->toBeGreaterThan(500);
});
it('benchmarks rendering throughput without caching', function () {
// Create template
$content = '<html><body>' . str_repeat('<p>Paragraph content</p>', 50) . '</body></html>';
$filePath = $this->testDir . '/pages/throughput2.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($filePath, $content);
$context = new TemplateContext(template: 'pages/throughput2', data: []);
// Simulate cache misses
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems(CacheItem::miss(CacheKey::fromString('test'))));
$this->cache->shouldReceive('set')->andReturn(true);
// Measure throughput
$iterations = 100; // Fewer iterations for uncached
$start = microtime(true);
for ($i = 0; $i < $iterations; $i++) {
$this->cacheManager->render($context, fn() => $content);
}
$duration = microtime(true) - $start;
$throughput = $iterations / $duration;
$this->perfResults['Throughput (uncached)']['throughput_per_sec'] = $throughput;
echo "\nThroughput (uncached): " . number_format($throughput, 0) . " renders/sec\n";
// Info-gathering: verify reasonable throughput (> 100 renders/sec)
expect($throughput)->toBeGreaterThan(100);
});
});
describe('Concurrent Load Simulation', function () {
it('benchmarks performance under concurrent load', function () {
// Create template
$content = '<html><body>Concurrent test content</body></html>';
$filePath = $this->testDir . '/pages/concurrent.view.php';
$dir = dirname($filePath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($filePath, $content);
$context = new TemplateContext(template: 'pages/concurrent', data: []);
// Simulate cache hits
$cacheKey = CacheKey::fromString('page:pages/concurrent:' . md5(serialize([])));
$cacheItem = CacheItem::hit($cacheKey, $content);
$this->cache->shouldReceive('get')->andReturn(CacheResult::fromItems($cacheItem));
// Simulate 10 concurrent requests with 100 renders each
$measurements = [];
for ($request = 0; $request < 10; $request++) {
$requestMeasurements = [];
for ($i = 0; $i < 100; $i++) {
$start = microtime(true);
$this->cacheManager->render($context, fn() => $content);
$requestMeasurements[] = (microtime(true) - $start) * 1000;
}
$measurements = array_merge($measurements, $requestMeasurements);
}
$stats = calculateStats($measurements);
$this->perfResults['Concurrent Load (10 requests)'] = $stats;
// Performance should remain stable under load
expect($stats['avg'])->toBeLessThan(5.0); // Average < 5ms (with test overhead)
expect($stats['p99'])->toBeLessThan(100.0); // 99% < 100ms
});
});
});
// Helper function for statistics calculation
function calculateStats(array $measurements): array
{
sort($measurements);
$count = count($measurements);
return [
'count' => $count,
'min' => min($measurements),
'max' => max($measurements),
'avg' => array_sum($measurements) / $count,
'median' => $measurements[(int)($count / 2)],
'p95' => $measurements[(int)($count * 0.95)],
'p99' => $measurements[(int)($count * 0.99)],
'stddev' => sqrt(array_sum(array_map(fn($x) => pow($x - (array_sum($measurements) / $count), 2), $measurements)) / $count)
];
}

View File

@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\View\Caching\Strategies\ComponentCacheStrategy;
use App\Framework\View\Caching\TemplateContext;
describe('ComponentCacheStrategy', function () {
beforeEach(function () {
$this->cache = Mockery::mock(Cache::class);
$this->strategy = new ComponentCacheStrategy($this->cache);
});
afterEach(function () {
Mockery::close();
});
describe('shouldCache()', function () {
it('returns true for templates with "component" in name', function () {
$context = new TemplateContext(
template: 'components/button',
data: ['text' => 'Click me']
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
it('returns true for templates with "partial" in name', function () {
$context = new TemplateContext(
template: 'partials/navigation',
data: ['items' => []]
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
it('returns true for nested component paths', function () {
$context = new TemplateContext(
template: 'views/components/card/header',
data: ['title' => 'Header']
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
it('returns true for partial in subdirectory', function () {
$context = new TemplateContext(
template: 'admin/partials/sidebar',
data: []
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
it('returns false for regular templates', function () {
$context = new TemplateContext(
template: 'pages/home',
data: []
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns false for layouts', function () {
$context = new TemplateContext(
template: 'layouts/main',
data: []
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('is case sensitive for component keyword', function () {
$context = new TemplateContext(
template: 'COMPONENTS/button', // Uppercase
data: []
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
});
describe('generateKey()', function () {
it('generates consistent keys for same component and data', function () {
$context = new TemplateContext(
template: 'components/button',
data: ['text' => 'Click', 'variant' => 'primary']
);
$key1 = $this->strategy->generateKey($context);
$key2 = $this->strategy->generateKey($context);
expect((string) $key1)->toBe((string) $key2);
});
it('generates different keys for different components', function () {
$context1 = new TemplateContext(
template: 'components/button',
data: ['text' => 'Click']
);
$context2 = new TemplateContext(
template: 'components/link',
data: ['text' => 'Click']
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
expect((string) $key1)->not->toBe((string) $key2);
});
it('generates different keys for different data', function () {
$context1 = new TemplateContext(
template: 'components/button',
data: ['text' => 'Submit']
);
$context2 = new TemplateContext(
template: 'components/button',
data: ['text' => 'Cancel']
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
expect((string) $key1)->not->toBe((string) $key2);
});
it('uses basename for nested component paths', function () {
$context1 = new TemplateContext(
template: 'views/components/button',
data: ['text' => 'Click']
);
$context2 = new TemplateContext(
template: 'other/components/button',
data: ['text' => 'Click']
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
// Same basename and data = same key
expect((string) $key1)->toBe((string) $key2);
});
it('includes component basename in cache key', function () {
$context = new TemplateContext(
template: 'components/alert',
data: ['message' => 'Success']
);
$key = $this->strategy->generateKey($context);
expect((string) $key)->toContain('component:alert:');
});
it('includes data hash in cache key', function () {
$context = new TemplateContext(
template: 'components/card',
data: ['title' => 'Test', 'content' => 'Content']
);
$key = $this->strategy->generateKey($context);
$keyString = (string) $key;
// Key should have format: component:card:{hash}
expect($keyString)->toStartWith('component:card:');
expect(strlen($keyString))->toBeGreaterThan(strlen('component:card:'));
});
it('generates same key for identical nested data structures', function () {
$data = [
'user' => ['name' => 'John', 'role' => 'admin'],
'settings' => ['theme' => 'dark']
];
$context1 = new TemplateContext(
template: 'components/profile',
data: $data
);
$context2 = new TemplateContext(
template: 'components/profile',
data: $data
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
expect((string) $key1)->toBe((string) $key2);
});
});
describe('getTtl()', function () {
it('returns 1800 seconds for all components', function () {
$context = new TemplateContext(
template: 'components/button',
data: []
);
expect($this->strategy->getTtl($context))->toBe(1800);
});
it('returns consistent TTL for different components', function () {
$context1 = new TemplateContext(
template: 'components/card',
data: []
);
$context2 = new TemplateContext(
template: 'partials/header',
data: []
);
expect($this->strategy->getTtl($context1))->toBe(1800);
expect($this->strategy->getTtl($context2))->toBe(1800);
});
it('returns consistent TTL regardless of data', function () {
$context1 = new TemplateContext(
template: 'components/button',
data: ['small' => 'data']
);
$context2 = new TemplateContext(
template: 'components/button',
data: ['large' => str_repeat('x', 1000)]
);
expect($this->strategy->getTtl($context1))->toBe(1800);
expect($this->strategy->getTtl($context2))->toBe(1800);
});
});
describe('canInvalidate()', function () {
it('returns true for component templates', function () {
expect($this->strategy->canInvalidate('components/button'))->toBeTrue();
});
it('returns true for partial templates', function () {
expect($this->strategy->canInvalidate('partials/navigation'))->toBeTrue();
});
it('returns true for nested component paths', function () {
expect($this->strategy->canInvalidate('views/components/card'))->toBeTrue();
});
it('returns false for regular templates', function () {
expect($this->strategy->canInvalidate('pages/home'))->toBeFalse();
});
it('returns false for layouts', function () {
expect($this->strategy->canInvalidate('layouts/main'))->toBeFalse();
});
});
describe('edge cases', function () {
it('handles empty data array', function () {
$context = new TemplateContext(
template: 'components/divider',
data: []
);
expect($this->strategy->shouldCache($context))->toBeTrue();
$key = $this->strategy->generateKey($context);
expect($key)->toBeInstanceOf(CacheKey::class);
});
it('handles complex nested data structures', function () {
$context = new TemplateContext(
template: 'components/table',
data: [
'headers' => ['Name', 'Email', 'Role'],
'rows' => [
['John Doe', 'john@example.com', 'admin'],
['Jane Smith', 'jane@example.com', 'user']
],
'pagination' => [
'page' => 1,
'total' => 10,
'perPage' => 20
]
]
);
$key = $this->strategy->generateKey($context);
expect($key)->toBeInstanceOf(CacheKey::class);
expect((string) $key)->toBeString();
expect(strlen((string) $key))->toBeGreaterThan(0);
});
it('handles special characters in component name', function () {
$context = new TemplateContext(
template: 'components/user-profile_card',
data: ['user_id' => 123]
);
expect($this->strategy->shouldCache($context))->toBeTrue();
$key = $this->strategy->generateKey($context);
expect((string) $key)->toContain('component:user-profile_card:');
});
it('treats "component" and "partial" equally for caching', function () {
$componentContext = new TemplateContext(
template: 'components/item',
data: ['id' => 1]
);
$partialContext = new TemplateContext(
template: 'partials/item',
data: ['id' => 1]
);
expect($this->strategy->shouldCache($componentContext))->toBeTrue();
expect($this->strategy->shouldCache($partialContext))->toBeTrue();
expect($this->strategy->getTtl($componentContext))->toBe(
$this->strategy->getTtl($partialContext)
);
});
it('handles component path with multiple segments', function () {
$context = new TemplateContext(
template: 'admin/dashboard/components/widget/chart',
data: ['data' => [1, 2, 3]]
);
expect($this->strategy->shouldCache($context))->toBeTrue();
$key = $this->strategy->generateKey($context);
// Basename should be 'chart'
expect((string) $key)->toContain('component:chart:');
});
it('generates valid cache key from data with objects', function () {
$context = new TemplateContext(
template: 'components/user-card',
data: [
'user' => (object) ['name' => 'John', 'email' => 'john@example.com'],
'timestamp' => new \DateTimeImmutable('2024-01-01 12:00:00')
]
);
$key = $this->strategy->generateKey($context);
expect($key)->toBeInstanceOf(CacheKey::class);
expect((string) $key)->toStartWith('component:user-card:');
});
});
});

View File

@@ -0,0 +1,425 @@
<?php
declare(strict_types=1);
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\View\Caching\Strategies\FragmentCacheStrategy;
use App\Framework\View\Caching\TemplateContext;
describe('FragmentCacheStrategy', function () {
beforeEach(function () {
$this->cache = Mockery::mock(Cache::class);
$this->strategy = new FragmentCacheStrategy($this->cache);
});
afterEach(function () {
Mockery::close();
});
describe('shouldCache()', function () {
it('returns true when fragment_id is set in metadata', function () {
$context = new TemplateContext(
template: 'page/dashboard',
data: ['stats' => [1, 2, 3]],
metadata: ['fragment_id' => 'user-stats']
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
it('returns false when fragment_id is not set', function () {
$context = new TemplateContext(
template: 'page/dashboard',
data: ['stats' => [1, 2, 3]],
metadata: []
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns false when metadata is empty', function () {
$context = new TemplateContext(
template: 'page/home',
data: ['content' => 'text']
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns true even with empty fragment_id string', function () {
$context = new TemplateContext(
template: 'page/dashboard',
data: [],
metadata: ['fragment_id' => '']
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
it('returns true with numeric fragment_id', function () {
$context = new TemplateContext(
template: 'page/report',
data: [],
metadata: ['fragment_id' => 123]
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
it('returns true regardless of other metadata keys', function () {
$context = new TemplateContext(
template: 'page/profile',
data: [],
metadata: [
'fragment_id' => 'user-profile',
'other_key' => 'value',
'version' => 2
]
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
});
describe('generateKey()', function () {
it('generates consistent keys for same fragment', function () {
$context = new TemplateContext(
template: 'page/dashboard',
data: ['count' => 5],
metadata: ['fragment_id' => 'stats']
);
$key1 = $this->strategy->generateKey($context);
$key2 = $this->strategy->generateKey($context);
expect((string) $key1)->toBe((string) $key2);
});
it('generates different keys for different fragment_ids', function () {
$context1 = new TemplateContext(
template: 'page/dashboard',
data: ['count' => 5],
metadata: ['fragment_id' => 'stats']
);
$context2 = new TemplateContext(
template: 'page/dashboard',
data: ['count' => 5],
metadata: ['fragment_id' => 'chart']
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
expect((string) $key1)->not->toBe((string) $key2);
});
it('generates different keys for different templates with same fragment', function () {
$context1 = new TemplateContext(
template: 'page/dashboard',
data: [],
metadata: ['fragment_id' => 'header']
);
$context2 = new TemplateContext(
template: 'page/profile',
data: [],
metadata: ['fragment_id' => 'header']
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
expect((string) $key1)->not->toBe((string) $key2);
});
it('generates different keys for different data', function () {
$context1 = new TemplateContext(
template: 'page/dashboard',
data: ['count' => 5],
metadata: ['fragment_id' => 'stats']
);
$context2 = new TemplateContext(
template: 'page/dashboard',
data: ['count' => 10],
metadata: ['fragment_id' => 'stats']
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
expect((string) $key1)->not->toBe((string) $key2);
});
it('uses "default" when fragment_id is missing', function () {
$context = new TemplateContext(
template: 'page/home',
data: ['content' => 'test'],
metadata: []
);
$key = $this->strategy->generateKey($context);
expect((string) $key)->toContain('fragment:page/home:default:');
});
it('includes fragment_id in cache key', function () {
$context = new TemplateContext(
template: 'page/dashboard',
data: [],
metadata: ['fragment_id' => 'user-stats']
);
$key = $this->strategy->generateKey($context);
expect((string) $key)->toContain('fragment:page/dashboard:user-stats:');
});
it('includes template name in cache key', function () {
$context = new TemplateContext(
template: 'reports/annual',
data: [],
metadata: ['fragment_id' => 'summary']
);
$key = $this->strategy->generateKey($context);
expect((string) $key)->toStartWith('fragment:reports/annual:summary:');
});
it('includes data hash in cache key', function () {
$context = new TemplateContext(
template: 'page/dashboard',
data: ['user_id' => 123, 'role' => 'admin'],
metadata: ['fragment_id' => 'permissions']
);
$key = $this->strategy->generateKey($context);
$keyString = (string) $key;
// Key should have format: fragment:{template}:{fragment_id}:{hash}
expect($keyString)->toStartWith('fragment:page/dashboard:permissions:');
expect(strlen($keyString))->toBeGreaterThan(strlen('fragment:page/dashboard:permissions:'));
});
it('handles numeric fragment_id', function () {
$context = new TemplateContext(
template: 'page/item',
data: [],
metadata: ['fragment_id' => 42]
);
$key = $this->strategy->generateKey($context);
expect((string) $key)->toContain('fragment:page/item:42:');
});
it('generates same key for identical complex data structures', function () {
$data = [
'items' => [
['id' => 1, 'name' => 'Item 1'],
['id' => 2, 'name' => 'Item 2']
],
'meta' => ['total' => 2, 'page' => 1]
];
$context1 = new TemplateContext(
template: 'list/items',
data: $data,
metadata: ['fragment_id' => 'item-list']
);
$context2 = new TemplateContext(
template: 'list/items',
data: $data,
metadata: ['fragment_id' => 'item-list']
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
expect((string) $key1)->toBe((string) $key2);
});
});
describe('getTtl()', function () {
it('returns 600 seconds for all fragments', function () {
$context = new TemplateContext(
template: 'page/dashboard',
data: [],
metadata: ['fragment_id' => 'stats']
);
expect($this->strategy->getTtl($context))->toBe(600);
});
it('returns consistent TTL for different fragments', function () {
$context1 = new TemplateContext(
template: 'page/dashboard',
data: [],
metadata: ['fragment_id' => 'stats']
);
$context2 = new TemplateContext(
template: 'page/profile',
data: [],
metadata: ['fragment_id' => 'bio']
);
expect($this->strategy->getTtl($context1))->toBe(600);
expect($this->strategy->getTtl($context2))->toBe(600);
});
it('returns consistent TTL regardless of data', function () {
$context1 = new TemplateContext(
template: 'page/dashboard',
data: ['small' => 'data'],
metadata: ['fragment_id' => 'header']
);
$context2 = new TemplateContext(
template: 'page/dashboard',
data: ['large' => str_repeat('x', 1000)],
metadata: ['fragment_id' => 'header']
);
expect($this->strategy->getTtl($context1))->toBe(600);
expect($this->strategy->getTtl($context2))->toBe(600);
});
it('returns consistent TTL when fragment_id is missing', function () {
$context = new TemplateContext(
template: 'page/home',
data: [],
metadata: []
);
expect($this->strategy->getTtl($context))->toBe(600);
});
});
describe('canInvalidate()', function () {
it('returns true for any template', function () {
expect($this->strategy->canInvalidate('page/home'))->toBeTrue();
expect($this->strategy->canInvalidate('components/button'))->toBeTrue();
expect($this->strategy->canInvalidate('layouts/main'))->toBeTrue();
});
it('returns true for empty string', function () {
expect($this->strategy->canInvalidate(''))->toBeTrue();
});
it('returns true for template with special characters', function () {
expect($this->strategy->canInvalidate('admin/users/edit-profile_form'))->toBeTrue();
});
});
describe('edge cases', function () {
it('handles empty data array', function () {
$context = new TemplateContext(
template: 'page/empty',
data: [],
metadata: ['fragment_id' => 'test']
);
expect($this->strategy->shouldCache($context))->toBeTrue();
$key = $this->strategy->generateKey($context);
expect($key)->toBeInstanceOf(CacheKey::class);
});
it('handles complex nested metadata', function () {
$context = new TemplateContext(
template: 'page/complex',
data: ['content' => 'test'],
metadata: [
'fragment_id' => 'main-content',
'version' => 2,
'options' => ['cache' => true, 'ttl' => 300]
]
);
expect($this->strategy->shouldCache($context))->toBeTrue();
$key = $this->strategy->generateKey($context);
expect((string) $key)->toContain('fragment:page/complex:main-content:');
});
it('handles special characters in fragment_id', function () {
$context = new TemplateContext(
template: 'page/dashboard',
data: [],
metadata: ['fragment_id' => 'user-stats_2024-Q1']
);
$key = $this->strategy->generateKey($context);
expect((string) $key)->toContain('fragment:page/dashboard:user-stats_2024-Q1:');
});
it('throws exception for very long fragment_id exceeding 250 chars', function () {
$longFragmentId = str_repeat('fragment-', 50); // Results in 500 chars
$context = new TemplateContext(
template: 'page/test',
data: [],
metadata: ['fragment_id' => $longFragmentId]
);
// CacheKey validates max 250 characters
expect(fn() => $this->strategy->generateKey($context))
->toThrow(\InvalidArgumentException::class, 'Cache key length exceeds maximum');
});
it('handles data with objects', function () {
$context = new TemplateContext(
template: 'page/dashboard',
data: [
'date' => new \DateTimeImmutable('2024-01-01 12:00:00'),
'user' => (object) ['id' => 1, 'name' => 'John']
],
metadata: ['fragment_id' => 'stats']
);
$key = $this->strategy->generateKey($context);
expect($key)->toBeInstanceOf(CacheKey::class);
expect((string) $key)->toStartWith('fragment:page/dashboard:stats:');
});
it('handles null fragment_id gracefully', function () {
$context = new TemplateContext(
template: 'page/test',
data: [],
metadata: ['fragment_id' => null]
);
// isset() returns false for null values
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('distinguishes between missing and null fragment_id', function () {
$contextMissing = new TemplateContext(
template: 'page/test',
data: [],
metadata: []
);
$contextNull = new TemplateContext(
template: 'page/test',
data: [],
metadata: ['fragment_id' => null]
);
expect($this->strategy->shouldCache($contextMissing))->toBeFalse();
expect($this->strategy->shouldCache($contextNull))->toBeFalse();
});
it('treats empty string fragment_id differently from null', function () {
$context = new TemplateContext(
template: 'page/test',
data: [],
metadata: ['fragment_id' => '']
);
// isset() returns true for empty string
expect($this->strategy->shouldCache($context))->toBeTrue();
$key = $this->strategy->generateKey($context);
expect((string) $key)->toStartWith('fragment:page/test::');
});
});
});

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\View\Caching\Strategies\FullPageCacheStrategy;
use App\Framework\View\Caching\TemplateContext;
describe('FullPageCacheStrategy', function () {
beforeEach(function () {
$this->cache = Mockery::mock(Cache::class);
$this->strategy = new FullPageCacheStrategy($this->cache);
});
afterEach(function () {
Mockery::close();
});
describe('shouldCache()', function () {
it('returns true for static content without user data', function () {
$context = new TemplateContext(
template: 'static-page',
data: ['title' => 'Welcome', 'content' => 'Hello World']
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
it('returns false when user data is present', function () {
$context = new TemplateContext(
template: 'user-dashboard',
data: ['user' => ['name' => 'John'], 'title' => 'Dashboard']
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns false when auth data is present', function () {
$context = new TemplateContext(
template: 'profile',
data: ['auth' => ['authenticated' => true]]
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns false when session data is present', function () {
$context = new TemplateContext(
template: 'checkout',
data: ['session' => ['cart_items' => 3]]
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns false when csrf token is present', function () {
$context = new TemplateContext(
template: 'contact-form',
data: ['csrf_token' => 'abc123']
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns false when flash messages are present', function () {
$context = new TemplateContext(
template: 'result-page',
data: ['flash' => ['success' => 'Saved!']]
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns false when errors are present', function () {
$context = new TemplateContext(
template: 'form',
data: ['errors' => ['email' => 'Invalid email']]
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns false when timestamp is present', function () {
$context = new TemplateContext(
template: 'news',
data: ['timestamp' => time()]
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('returns true when only non-volatile data is present', function () {
$context = new TemplateContext(
template: 'product-page',
data: [
'product' => ['name' => 'Laptop', 'price' => 999],
'reviews' => [['rating' => 5]]
]
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
});
describe('generateKey()', function () {
it('generates consistent keys for same template and data', function () {
$context = new TemplateContext(
template: 'product',
data: ['id' => 123, 'name' => 'Laptop']
);
$key1 = $this->strategy->generateKey($context);
$key2 = $this->strategy->generateKey($context);
expect((string) $key1)->toBe((string) $key2);
});
it('generates different keys for different templates', function () {
$context1 = new TemplateContext(
template: 'product',
data: ['id' => 123]
);
$context2 = new TemplateContext(
template: 'category',
data: ['id' => 123]
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
expect((string) $key1)->not->toBe((string) $key2);
});
it('generates different keys for different data', function () {
$context1 = new TemplateContext(
template: 'product',
data: ['id' => 123]
);
$context2 = new TemplateContext(
template: 'product',
data: ['id' => 456]
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
expect((string) $key1)->not->toBe((string) $key2);
});
it('generates same key when volatile data changes', function () {
$context1 = new TemplateContext(
template: 'page',
data: ['title' => 'Test', 'csrf_token' => 'abc']
);
$context2 = new TemplateContext(
template: 'page',
data: ['title' => 'Test', 'csrf_token' => 'xyz']
);
$key1 = $this->strategy->generateKey($context1);
$key2 = $this->strategy->generateKey($context2);
// CSRF token sollte ignoriert werden
expect((string) $key1)->toBe((string) $key2);
});
it('includes template name in cache key', function () {
$context = new TemplateContext(
template: 'about-page',
data: ['content' => 'About us']
);
$key = $this->strategy->generateKey($context);
expect((string) $key)->toContain('page:about-page:');
});
});
describe('getTtl()', function () {
it('returns 3600 seconds for layout templates', function () {
$context = new TemplateContext(
template: 'layouts/main',
data: []
);
expect($this->strategy->getTtl($context))->toBe(3600);
});
it('returns 7200 seconds for static templates', function () {
$context = new TemplateContext(
template: 'static/about',
data: []
);
expect($this->strategy->getTtl($context))->toBe(7200);
});
it('returns 1800 seconds for content pages', function () {
$context = new TemplateContext(
template: 'page/home',
data: []
);
expect($this->strategy->getTtl($context))->toBe(1800);
});
it('returns 900 seconds for default templates', function () {
$context = new TemplateContext(
template: 'misc-template',
data: []
);
expect($this->strategy->getTtl($context))->toBe(900);
});
});
describe('canInvalidate()', function () {
it('returns true for all templates', function () {
expect($this->strategy->canInvalidate('any-template'))->toBeTrue();
expect($this->strategy->canInvalidate('layout'))->toBeTrue();
expect($this->strategy->canInvalidate('component'))->toBeTrue();
});
});
describe('edge cases', function () {
it('handles empty data array', function () {
$context = new TemplateContext(
template: 'empty',
data: []
);
expect($this->strategy->shouldCache($context))->toBeTrue();
});
it('handles nested user data', function () {
$context = new TemplateContext(
template: 'nested',
data: [
'content' => ['text' => 'Hello'],
'user' => ['name' => 'John'] // Top-level user key
]
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('handles mixed volatile and non-volatile data', function () {
$context = new TemplateContext(
template: 'mixed',
data: [
'product' => ['id' => 1],
'flash' => ['message' => 'Success']
]
);
expect($this->strategy->shouldCache($context))->toBeFalse();
});
it('generates valid cache key from complex data', function () {
$context = new TemplateContext(
template: 'complex',
data: [
'nested' => ['deep' => ['value' => 123]],
'array' => [1, 2, 3],
'string' => 'test'
]
);
$key = $this->strategy->generateKey($context);
expect($key)->toBeInstanceOf(CacheKey::class);
expect((string) $key)->toBeString();
expect(strlen((string) $key))->toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,468 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Framework\View\Processors;
use App\Framework\LiveComponents\Contracts\ComponentRegistryInterface;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Performance\CompiledComponentMetadata;
use App\Framework\LiveComponents\Performance\ComponentMetadataCacheInterface;
use App\Framework\LiveComponents\Performance\ComponentPropertyMetadata;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use App\Framework\Meta\MetaData;
use App\Framework\Template\Parser\DomTemplateParser;
use App\Framework\View\Contracts\HtmlComponentRegistryInterface;
use App\Framework\View\DomComponentService;
use App\Framework\View\Processors\XComponentProcessor;
use App\Framework\View\RenderContext;
describe('XComponentProcessor', function () {
beforeEach(function () {
// Mock dependencies via Interfaces
$this->liveComponentRegistry = mock(ComponentRegistryInterface::class);
$this->htmlComponentRegistry = mock(HtmlComponentRegistryInterface::class);
$this->metadataCache = mock(ComponentMetadataCacheInterface::class);
$this->componentService = new DomComponentService();
$this->processor = new XComponentProcessor(
$this->liveComponentRegistry,
$this->htmlComponentRegistry,
$this->metadataCache,
$this->componentService
);
$this->parser = new DomTemplateParser();
});
describe('LiveComponent Processing', function () {
it('processes LiveComponent with basic props', function () {
$html = '<html><body><x-counter id="demo" initialValue="5" /></body></html>';
$dom = $this->parser->parseToWrapper($html);
// Mock LiveComponent
$mockComponent = mock(LiveComponentContract::class);
$mockComponent->shouldReceive('getId')
->andReturn(ComponentId::create('counter', 'demo'));
$mockComponent->shouldReceive('getData')
->andReturn(ComponentData::fromArray(['initialValue' => 5]));
$mockComponent->shouldReceive('getRenderData')
->andReturn(new ComponentRenderData('counter-template', ['value' => 5]));
// Setup registry mocks
$this->liveComponentRegistry->shouldReceive('isRegistered')
->with('counter')
->andReturn(true);
$this->liveComponentRegistry->shouldReceive('getClassName')
->with('counter')
->andReturn('TestCounterComponent');
$this->liveComponentRegistry->shouldReceive('resolve')
->once()
->andReturn($mockComponent);
$this->liveComponentRegistry->shouldReceive('renderWithWrapper')
->with($mockComponent)
->andReturn('<div data-component-id="counter:demo">Counter HTML</div>');
// Mock metadata for validation
$mockMetadata = new CompiledComponentMetadata(
className: 'TestCounterComponent',
componentName: 'counter',
properties: [
'initialValue' => new ComponentPropertyMetadata(
name: 'initialValue',
type: 'int',
isPublic: true,
isReadonly: false
)
],
actions: [],
constructorParams: []
);
$this->metadataCache->shouldReceive('get')
->with('TestCounterComponent')
->andReturn($mockMetadata);
// Process
$context = new RenderContext(
template: 'test-template',
metaData: new MetaData('Test Component Processing'),
data: []
);
$result = $this->processor->process($dom, $context);
// Assert
$html = $result->document->saveHTML();
expect($html)->toContain('data-component-id="counter:demo"');
expect($html)->toContain('Counter HTML');
});
it('coerces prop types correctly', function () {
$html = '<html><body><x-test
stringProp="text"
intProp="123"
floatProp="12.5"
boolTrue="true"
boolFalse="false"
nullProp="null"
arrayProp="[1,2,3]"
/></body></html>';
$dom = $this->parser->parseToWrapper($html);
$capturedProps = null;
$mockComponent = mock(LiveComponentContract::class);
$mockComponent->shouldReceive('getId')
->andReturn(ComponentId::create('test', 'test-auto'));
$mockComponent->shouldReceive('getData')
->andReturn(ComponentData::fromArray([]));
$mockComponent->shouldReceive('getRenderData')
->andReturn(new ComponentRenderData('test-template', []));
$this->liveComponentRegistry->shouldReceive('isRegistered')
->with('test')
->andReturn(true);
$this->liveComponentRegistry->shouldReceive('getClassName')
->with('test')
->andReturn('TestComponent');
$this->liveComponentRegistry->shouldReceive('resolve')
->with(\Mockery::type(ComponentId::class), \Mockery::on(function ($data) use (&$capturedProps) {
$capturedProps = $data->toArray();
return true;
}))
->andReturn($mockComponent);
$this->liveComponentRegistry->shouldReceive('renderWithWrapper')
->andReturn('<div>Test</div>');
// Mock metadata - accept all props
$mockMetadata = new CompiledComponentMetadata(
className: 'TestComponent',
componentName: 'test',
properties: [
'stringProp' => new ComponentPropertyMetadata('stringProp', 'string', true, false),
'intProp' => new ComponentPropertyMetadata('intProp', 'int', true, false),
'floatProp' => new ComponentPropertyMetadata('floatProp', 'float', true, false),
'boolTrue' => new ComponentPropertyMetadata('boolTrue', 'bool', true, false),
'boolFalse' => new ComponentPropertyMetadata('boolFalse', 'bool', true, false),
'nullProp' => new ComponentPropertyMetadata('nullProp', 'mixed', true, false),
'arrayProp' => new ComponentPropertyMetadata('arrayProp', 'array', true, false),
],
actions: [],
constructorParams: []
);
$this->metadataCache->shouldReceive('get')
->andReturn($mockMetadata);
// Process
$context = new RenderContext(
template: 'test-template',
metaData: new MetaData('Test Component Processing'),
data: []
);
$this->processor->process($dom, $context);
// Assert type coercion
expect($capturedProps['stringProp'])->toBe('text');
expect($capturedProps['intProp'])->toBe(123);
expect($capturedProps['floatProp'])->toBe(12.5);
expect($capturedProps['boolTrue'])->toBeTrue();
expect($capturedProps['boolFalse'])->toBeFalse();
expect($capturedProps['nullProp'])->toBeNull();
expect($capturedProps['arrayProp'])->toBe([1, 2, 3]);
});
it('validates props against ComponentMetadata', function () {
$html = '<html><body><x-counter id="demo" invalidProp="test" /></body></html>';
$dom = $this->parser->parseToWrapper($html);
$this->liveComponentRegistry->shouldReceive('isRegistered')
->with('counter')
->andReturn(true);
$this->liveComponentRegistry->shouldReceive('getClassName')
->with('counter')
->andReturn('TestCounterComponent');
// Mock metadata without invalidProp
$mockMetadata = new CompiledComponentMetadata(
className: 'TestCounterComponent',
componentName: 'counter',
properties: [
'initialValue' => new ComponentPropertyMetadata(
name: 'initialValue',
type: 'int',
isPublic: true,
isReadonly: false
)
],
actions: [],
constructorParams: []
);
$this->metadataCache->shouldReceive('get')
->with('TestCounterComponent')
->andReturn($mockMetadata);
// Process - should handle error gracefully in dev mode
$_ENV['APP_ENV'] = 'development';
$context = new RenderContext(
template: 'test-template',
metaData: new MetaData('Test Component Processing'),
data: []
);
$result = $this->processor->process($dom, $context);
// Should contain error message in development
$html = $result->document->saveHTML();
expect($html)->toContain('XComponentProcessor Error');
});
it('generates unique ID if not provided', function () {
$html = '<html><body><x-counter initialValue="0" /></body></html>';
$dom = $this->parser->parseToWrapper($html);
$mockComponent = mock(LiveComponentContract::class);
$mockComponent->shouldReceive('getId')
->andReturn(ComponentId::create('counter', 'counter-auto'));
$mockComponent->shouldReceive('getData')
->andReturn(ComponentData::fromArray([]));
$mockComponent->shouldReceive('getRenderData')
->andReturn(new ComponentRenderData('test', []));
$this->liveComponentRegistry->shouldReceive('isRegistered')
->andReturn(true);
$this->liveComponentRegistry->shouldReceive('getClassName')
->andReturn('TestComponent');
$this->liveComponentRegistry->shouldReceive('resolve')
->with(\Mockery::on(function ($id) {
// Should have auto-generated ID
return str_starts_with($id->toString(), 'counter:counter-');
}), \Mockery::any())
->andReturn($mockComponent);
$this->liveComponentRegistry->shouldReceive('renderWithWrapper')
->andReturn('<div>Counter</div>');
$mockMetadata = new CompiledComponentMetadata(
className: 'TestComponent',
componentName: 'counter',
properties: [
'initialValue' => new ComponentPropertyMetadata(
name: 'initialValue',
type: 'int',
isPublic: true,
isReadonly: false
)
],
actions: [],
constructorParams: []
);
$this->metadataCache->shouldReceive('get')->andReturn($mockMetadata);
$context = new RenderContext(
template: 'test-template',
metaData: new MetaData('Test Component Processing'),
data: []
);
$this->processor->process($dom, $context);
// Assertion in mock expectations above
expect(true)->toBeTrue();
});
});
describe('HTML Component Processing', function () {
it('processes HTML Component', function () {
$html = '<html><body><x-button variant="primary">Click me</x-button></body></html>';
$dom = $this->parser->parseToWrapper($html);
// LiveComponent not registered
$this->liveComponentRegistry->shouldReceive('isRegistered')
->with('button')
->andReturn(false);
// HTML Component registered
$this->htmlComponentRegistry->shouldReceive('has')
->with('button')
->andReturn(true);
$this->htmlComponentRegistry->shouldReceive('render')
->with('button', 'Click me', ['variant' => 'primary'])
->andReturn('<button class="btn btn-primary">Click me</button>');
// Process
$context = new RenderContext(
template: 'test-template',
metaData: new MetaData('Test Component Processing'),
data: []
);
$result = $this->processor->process($dom, $context);
// Assert
$html = $result->document->saveHTML();
expect($html)->toContain('<button class="btn btn-primary">Click me</button>');
});
it('extracts content from HTML Component', function () {
$html = '<html><body><x-card>Card content here</x-card></body></html>';
$dom = $this->parser->parseToWrapper($html);
$this->liveComponentRegistry->shouldReceive('isRegistered')
->andReturn(false);
$this->htmlComponentRegistry->shouldReceive('has')
->with('card')
->andReturn(true);
$capturedContent = null;
$this->htmlComponentRegistry->shouldReceive('render')
->with('card', \Mockery::capture($capturedContent), \Mockery::any())
->andReturn('<div class="card">Card content here</div>');
// Process
$context = new RenderContext(
template: 'test-template',
metaData: new MetaData('Test Component Processing'),
data: []
);
$this->processor->process($dom, $context);
// Assert content was captured
expect($capturedContent)->toBe('Card content here');
});
});
describe('Error Handling', function () {
it('shows helpful error when component not found', function () {
$html = '<html><body><x-unknown id="test" /></body></html>';
$dom = $this->parser->parseToWrapper($html);
// Neither registry has the component
$this->liveComponentRegistry->shouldReceive('isRegistered')
->with('unknown')
->andReturn(false);
$this->htmlComponentRegistry->shouldReceive('has')
->with('unknown')
->andReturn(false);
// Mock available components for error message
$this->liveComponentRegistry->shouldReceive('getAllComponentNames')
->andReturn(['counter', 'datatable']);
$this->htmlComponentRegistry->shouldReceive('getAllComponentNames')
->andReturn(['button', 'card']);
// Development mode
$_ENV['APP_ENV'] = 'development';
// Process
$context = new RenderContext(
template: 'test-template',
metaData: new MetaData('Test Component Processing'),
data: []
);
$result = $this->processor->process($dom, $context);
// Should show error with available components
$html = $result->document->saveHTML();
expect($html)->toContain('Unknown component');
expect($html)->toContain('counter, datatable');
expect($html)->toContain('button, card');
});
it('removes component silently in production', function () {
$html = '<html><body><p>Before</p><x-unknown /><p>After</p></body></html>';
$dom = $this->parser->parseToWrapper($html);
$this->liveComponentRegistry->shouldReceive('isRegistered')->andReturn(false);
$this->htmlComponentRegistry->shouldReceive('has')->andReturn(false);
$this->liveComponentRegistry->shouldReceive('getAllComponentNames')->andReturn([]);
$this->htmlComponentRegistry->shouldReceive('getAllComponentNames')->andReturn([]);
// Production mode
$_ENV['APP_ENV'] = 'production';
// Process
$context = new RenderContext(
template: 'test-template',
metaData: new MetaData('Test Component Processing'),
data: []
);
$result = $this->processor->process($dom, $context);
$html = $result->document->saveHTML();
// Component should be removed, but surrounding content remains
expect($html)->toContain('Before');
expect($html)->toContain('After');
expect($html)->not->toContain('x-unknown');
expect($html)->not->toContain('error'); // No error message
});
});
describe('Auto-Detection Priority', function () {
it('prioritizes LiveComponent over HTML Component', function () {
// Component registered in BOTH registries
$html = '<html><body><x-button id="test" /></body></html>';
$dom = $this->parser->parseToWrapper($html);
// BOTH return true
$this->liveComponentRegistry->shouldReceive('isRegistered')
->with('button')
->andReturn(true); // LiveComponent wins!
$mockComponent = mock(LiveComponentContract::class);
$mockComponent->shouldReceive('getId')
->andReturn(ComponentId::create('button', 'test'));
$mockComponent->shouldReceive('getData')
->andReturn(ComponentData::fromArray([]));
$mockComponent->shouldReceive('getRenderData')
->andReturn(new ComponentRenderData('test', []));
$this->liveComponentRegistry->shouldReceive('getClassName')
->andReturn('TestButtonComponent');
$this->liveComponentRegistry->shouldReceive('resolve')
->andReturn($mockComponent);
$this->liveComponentRegistry->shouldReceive('renderWithWrapper')
->with($mockComponent)
->andReturn('<button data-component-id="button:test">LiveComponent Button</button>');
$mockMetadata = new CompiledComponentMetadata(
className: 'TestButtonComponent',
componentName: 'button',
properties: [],
actions: [],
constructorParams: []
);
$this->metadataCache->shouldReceive('get')->andReturn($mockMetadata);
// HTML Component should NOT be called
$this->htmlComponentRegistry->shouldNotReceive('render');
// Process
$context = new RenderContext(
template: 'test-template',
metaData: new MetaData('Test Component Processing'),
data: []
);
$result = $this->processor->process($dom, $context);
$html = $result->document->saveHTML();
expect($html)->toContain('LiveComponent Button');
expect($html)->toContain('data-component-id');
});
});
});