- 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.
283 lines
10 KiB
PHP
283 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Framework\Cache\Cache;
|
|
use App\Framework\Cache\CacheItem;
|
|
use App\Framework\Cache\CacheKey;
|
|
use App\Framework\Core\ValueObjects\Duration;
|
|
use App\Framework\LiveComponents\ComponentCacheManager;
|
|
use App\Framework\LiveComponents\Contracts\LiveComponent;
|
|
use App\Framework\LiveComponents\ValueObjects\CacheConfig;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentData;
|
|
use App\Framework\LiveComponents\ValueObjects\ComponentId;
|
|
|
|
describe('ComponentCacheManager', function () {
|
|
beforeEach(function () {
|
|
$this->cache = Mockery::mock(Cache::class);
|
|
$this->cacheManager = new ComponentCacheManager($this->cache);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Mockery::close();
|
|
});
|
|
|
|
it('caches component with basic config', function () {
|
|
$component = Mockery::mock(LiveComponent::class);
|
|
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('stats:user-123'));
|
|
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['views' => 100]));
|
|
|
|
$config = new CacheConfig(
|
|
enabled: true,
|
|
ttl: Duration::fromMinutes(5),
|
|
varyBy: [],
|
|
staleWhileRevalidate: false
|
|
);
|
|
|
|
$html = '<div class="stats">Views: 100</div>';
|
|
|
|
$this->cache
|
|
->shouldReceive('set')
|
|
->once()
|
|
->with(Mockery::on(function (CacheItem $item) use ($html) {
|
|
return $item->value === $html
|
|
&& $item->ttl->toSeconds() === 300;
|
|
}))
|
|
->andReturn(true);
|
|
|
|
$result = $this->cacheManager->set($component, $html, $config);
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('generates cache key with varyBy parameters', function () {
|
|
$component = Mockery::mock(LiveComponent::class);
|
|
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('products:filter'));
|
|
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([
|
|
'category' => 'electronics',
|
|
'price_min' => 100,
|
|
'price_max' => 500,
|
|
'sort' => 'price_asc',
|
|
]));
|
|
|
|
$config = new CacheConfig(
|
|
enabled: true,
|
|
ttl: Duration::fromMinutes(10),
|
|
varyBy: ['category', 'price_min', 'price_max'],
|
|
staleWhileRevalidate: false
|
|
);
|
|
|
|
$html = '<div>Product list</div>';
|
|
|
|
$this->cache
|
|
->shouldReceive('set')
|
|
->once()
|
|
->with(Mockery::on(function (CacheItem $item) {
|
|
$keyString = $item->key->toString();
|
|
|
|
// Key should include component ID and varyBy parameters
|
|
return str_contains($keyString, 'products:filter')
|
|
&& str_contains($keyString, 'electronics')
|
|
&& str_contains($keyString, '100')
|
|
&& str_contains($keyString, '500');
|
|
}))
|
|
->andReturn(true);
|
|
|
|
$this->cacheManager->set($component, $html, $config);
|
|
});
|
|
|
|
it('retrieves cached component by exact key match', function () {
|
|
$component = Mockery::mock(LiveComponent::class);
|
|
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('stats:user-123'));
|
|
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['views' => 100]));
|
|
|
|
$config = new CacheConfig(
|
|
enabled: true,
|
|
ttl: Duration::fromMinutes(5)
|
|
);
|
|
|
|
$cachedHtml = '<div class="stats">Views: 100</div>';
|
|
|
|
$this->cache
|
|
->shouldReceive('get')
|
|
->once()
|
|
->andReturn(CacheItem::forGetting(
|
|
key: CacheKey::fromString('livecomponent:stats:user-123'),
|
|
value: $cachedHtml
|
|
));
|
|
|
|
$result = $this->cacheManager->get($component, $config);
|
|
|
|
expect($result)->toBe($cachedHtml);
|
|
});
|
|
|
|
it('returns null when cache miss', function () {
|
|
$component = Mockery::mock(LiveComponent::class);
|
|
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('stats:user-999'));
|
|
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
|
|
|
|
$config = new CacheConfig(enabled: true, ttl: Duration::fromMinutes(5));
|
|
|
|
$this->cache
|
|
->shouldReceive('get')
|
|
->once()
|
|
->andReturn(null);
|
|
|
|
$result = $this->cacheManager->get($component, $config);
|
|
|
|
expect($result)->toBeNull();
|
|
});
|
|
|
|
it('invalidates component cache', function () {
|
|
$component = Mockery::mock(LiveComponent::class);
|
|
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('cart:user-123'));
|
|
|
|
$this->cache
|
|
->shouldReceive('forget')
|
|
->once()
|
|
->with(Mockery::on(function ($key) {
|
|
return str_contains($key->toString(), 'cart:user-123');
|
|
}))
|
|
->andReturn(true);
|
|
|
|
$result = $this->cacheManager->invalidate($component);
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
it('supports stale-while-revalidate pattern', function () {
|
|
$component = Mockery::mock(LiveComponent::class);
|
|
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('news:feed'));
|
|
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
|
|
|
|
$config = new CacheConfig(
|
|
enabled: true,
|
|
ttl: Duration::fromMinutes(5),
|
|
varyBy: [],
|
|
staleWhileRevalidate: true,
|
|
staleWhileRevalidateTtl: Duration::fromMinutes(60)
|
|
);
|
|
|
|
$html = '<div>News feed</div>';
|
|
|
|
$this->cache
|
|
->shouldReceive('set')
|
|
->once()
|
|
->with(Mockery::on(function (CacheItem $item) {
|
|
// SWR should use extended TTL
|
|
return $item->ttl->toSeconds() === 3600; // 60 minutes
|
|
}))
|
|
->andReturn(true);
|
|
|
|
$this->cacheManager->set($component, $html, $config);
|
|
});
|
|
|
|
it('varyBy with different values creates different cache keys', function () {
|
|
$component1 = Mockery::mock(LiveComponent::class);
|
|
$component1->shouldReceive('getId')->andReturn(ComponentId::fromString('search:results'));
|
|
$component1->shouldReceive('getData')->andReturn(ComponentData::fromArray([
|
|
'query' => 'laptop',
|
|
'page' => 1,
|
|
]));
|
|
|
|
$component2 = Mockery::mock(LiveComponent::class);
|
|
$component2->shouldReceive('getId')->andReturn(ComponentId::fromString('search:results'));
|
|
$component2->shouldReceive('getData')->andReturn(ComponentData::fromArray([
|
|
'query' => 'laptop',
|
|
'page' => 2,
|
|
]));
|
|
|
|
$config = new CacheConfig(
|
|
enabled: true,
|
|
ttl: Duration::fromMinutes(10),
|
|
varyBy: ['query', 'page']
|
|
);
|
|
|
|
$capturedKeys = [];
|
|
|
|
$this->cache
|
|
->shouldReceive('set')
|
|
->twice()
|
|
->with(Mockery::on(function (CacheItem $item) use (&$capturedKeys) {
|
|
$capturedKeys[] = $item->key->toString();
|
|
|
|
return true;
|
|
}))
|
|
->andReturn(true);
|
|
|
|
$this->cacheManager->set($component1, '<div>Page 1</div>', $config);
|
|
$this->cacheManager->set($component2, '<div>Page 2</div>', $config);
|
|
|
|
// Keys should be different because page number differs
|
|
expect($capturedKeys[0])->not->toBe($capturedKeys[1]);
|
|
expect($capturedKeys[0])->toContain('laptop');
|
|
expect($capturedKeys[1])->toContain('laptop');
|
|
});
|
|
|
|
it('ignores cache when config disabled', function () {
|
|
$component = Mockery::mock(LiveComponent::class);
|
|
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('realtime:feed'));
|
|
|
|
$config = new CacheConfig(enabled: false, ttl: Duration::fromMinutes(5));
|
|
|
|
$this->cache->shouldNotReceive('set');
|
|
$this->cache->shouldNotReceive('get');
|
|
|
|
$result = $this->cacheManager->set($component, '<div>Feed</div>', $config);
|
|
expect($result)->toBeFalse();
|
|
|
|
$cached = $this->cacheManager->get($component, $config);
|
|
expect($cached)->toBeNull();
|
|
});
|
|
|
|
it('handles empty varyBy array correctly', function () {
|
|
$component = Mockery::mock(LiveComponent::class);
|
|
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('static:banner'));
|
|
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray(['message' => 'Welcome']));
|
|
|
|
$config = new CacheConfig(
|
|
enabled: true,
|
|
ttl: Duration::fromHours(1),
|
|
varyBy: [] // No variation
|
|
);
|
|
|
|
$this->cache
|
|
->shouldReceive('set')
|
|
->once()
|
|
->with(Mockery::on(function (CacheItem $item) {
|
|
// Should only include component ID, no variation parameters
|
|
return str_contains($item->key->toString(), 'static:banner');
|
|
}))
|
|
->andReturn(true);
|
|
|
|
$this->cacheManager->set($component, '<div>Banner</div>', $config);
|
|
});
|
|
|
|
it('respects custom TTL durations', function () {
|
|
$component = Mockery::mock(LiveComponent::class);
|
|
$component->shouldReceive('getId')->andReturn(ComponentId::fromString('test:component'));
|
|
$component->shouldReceive('getData')->andReturn(ComponentData::fromArray([]));
|
|
|
|
$testCases = [
|
|
Duration::fromSeconds(30) => 30,
|
|
Duration::fromMinutes(15) => 900,
|
|
Duration::fromHours(2) => 7200,
|
|
Duration::fromDays(1) => 86400,
|
|
];
|
|
|
|
foreach ($testCases as $duration => $expectedSeconds) {
|
|
$config = new CacheConfig(enabled: true, ttl: $duration);
|
|
|
|
$this->cache
|
|
->shouldReceive('set')
|
|
->once()
|
|
->with(Mockery::on(function (CacheItem $item) use ($expectedSeconds) {
|
|
return $item->ttl->toSeconds() === $expectedSeconds;
|
|
}))
|
|
->andReturn(true);
|
|
|
|
$this->cacheManager->set($component, '<div>Test</div>', $config);
|
|
}
|
|
});
|
|
});
|