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); } }); });