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 = '
Views: 100
';
$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 = 'Product list
';
$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 = 'Views: 100
';
$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 = 'News feed
';
$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, 'Page 1
', $config);
$this->cacheManager->set($component2, 'Page 2
', $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, 'Feed
', $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, 'Banner
', $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, 'Test
', $config);
}
});
});