post('/live-component/stats:user-123', [ 'method' => 'refresh', 'state' => ['views' => 100], 'cache_config' => $config->toArray(), ]); expect($response1->status)->toBe(Status::OK); $html1 = $response1->jsonData['html']; // Second request - should hit cache $response2 = $this->post('/live-component/stats:user-123', [ 'method' => 'refresh', 'state' => ['views' => 100], 'cache_config' => $config->toArray(), ]); expect($response2->status)->toBe(Status::OK); $html2 = $response2->jsonData['html']; // HTML should be identical (from cache) expect($html2)->toBe($html1); }); it('varies cache by specified parameters', function () { $config = new CacheConfig( enabled: true, ttl: Duration::fromMinutes(10), varyBy: ['category', 'page'] ); // Request with category=electronics, page=1 $response1 = $this->post('/live-component/products:filter', [ 'method' => 'filter', 'state' => [ 'category' => 'electronics', 'page' => 1, 'results' => [], ], 'cache_config' => $config->toArray(), ]); // Request with category=electronics, page=2 (different page) $response2 = $this->post('/live-component/products:filter', [ 'method' => 'filter', 'state' => [ 'category' => 'electronics', 'page' => 2, 'results' => [], ], 'cache_config' => $config->toArray(), ]); // Should be different results (different cache keys) expect($response1->jsonData['html'])->not->toBe($response2->jsonData['html']); // Request with category=books, page=1 (different category) $response3 = $this->post('/live-component/products:filter', [ 'method' => 'filter', 'state' => [ 'category' => 'books', 'page' => 1, 'results' => [], ], 'cache_config' => $config->toArray(), ]); // Should be different from electronics expect($response3->jsonData['html'])->not->toBe($response1->jsonData['html']); }); it('supports stale-while-revalidate pattern', function () { $config = new CacheConfig( enabled: true, ttl: Duration::fromSeconds(1), // Short TTL staleWhileRevalidate: true, staleWhileRevalidateTtl: Duration::fromMinutes(10) // Long SWR TTL ); // First request - cache miss $response1 = $this->post('/live-component/news:feed', [ 'method' => 'refresh', 'state' => ['items' => []], 'cache_config' => $config->toArray(), ]); expect($response1->status)->toBe(Status::OK); // Wait for TTL to expire (but within SWR window) sleep(2); // Second request - should serve stale content $response2 = $this->post('/live-component/news:feed', [ 'method' => 'refresh', 'state' => ['items' => []], 'cache_config' => $config->toArray(), ]); expect($response2->status)->toBe(Status::OK); // Should have cache-control header indicating stale if (isset($response2->headers['Cache-Control'])) { expect($response2->headers['Cache-Control'])->toContain('stale-while-revalidate'); } }); it('invalidates cache on component update', function () { $config = new CacheConfig( enabled: true, ttl: Duration::fromMinutes(5) ); // First render - cache $response1 = $this->post('/live-component/counter:demo', [ 'method' => 'increment', 'state' => ['count' => 0], 'cache_config' => $config->toArray(), ]); $count1 = $response1->jsonData['state']['count']; // Update action - should invalidate cache $response2 = $this->post('/live-component/counter:demo', [ 'method' => 'increment', 'params' => ['amount' => 10], 'state' => ['count' => $count1], 'cache_config' => $config->toArray(), ]); $count2 = $response2->jsonData['state']['count']; // Count should have incremented (cache invalidated) expect($count2)->toBeGreaterThan($count1); }); it('respects cache disabled config', function () { $config = CacheConfig::disabled(); // First request $response1 = $this->post('/live-component/realtime:feed', [ 'method' => 'refresh', 'state' => ['timestamp' => time()], 'cache_config' => $config->toArray(), ]); $timestamp1 = $response1->jsonData['state']['timestamp']; sleep(1); // Second request - should not use cache $response2 = $this->post('/live-component/realtime:feed', [ 'method' => 'refresh', 'state' => ['timestamp' => time()], 'cache_config' => $config->toArray(), ]); $timestamp2 = $response2->jsonData['state']['timestamp']; // Timestamps should be different (cache disabled) expect($timestamp2)->toBeGreaterThanOrEqual($timestamp1); }); it('caches fragments separately from full render', function () { $config = new CacheConfig( enabled: true, ttl: Duration::fromMinutes(5), varyBy: ['fragments'] ); // Request full render $fullResponse = $this->post('/live-component/card:demo', [ 'method' => 'refresh', 'state' => ['title' => 'Card Title', 'content' => 'Card Content'], 'cache_config' => $config->toArray(), ]); // Request fragment render $fragmentResponse = $this->post('/live-component/card:demo', [ 'method' => 'refresh', 'state' => ['title' => 'Card Title', 'content' => 'Card Content'], 'fragments' => ['card-header'], 'cache_config' => $config->toArray(), ]); // Should cache both independently expect($fullResponse->jsonData)->toHaveKey('html'); expect($fragmentResponse->jsonData)->toHaveKey('fragments'); }); it('handles cache for concurrent requests', function () { $config = new CacheConfig( enabled: true, ttl: Duration::fromMinutes(5) ); // Make multiple concurrent requests $responses = []; for ($i = 0; $i < 5; $i++) { $responses[] = $this->post('/live-component/stats:global', [ 'method' => 'refresh', 'state' => ['total_users' => 1000], 'cache_config' => $config->toArray(), ]); } // All should succeed foreach ($responses as $response) { expect($response->status)->toBe(Status::OK); } // All should have same HTML (from cache) $firstHtml = $responses[0]->jsonData['html']; foreach (array_slice($responses, 1) as $response) { expect($response->jsonData['html'])->toBe($firstHtml); } }); });