import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { spawn, ChildProcess } from 'child_process'; import { createClient, RedisClientType } from 'redis'; import path from 'path'; const PORT = Number(process.env.CACHE_COMPONENTS_PORT || '3065'); const BASE_URL = `http://localhost:${PORT}`; describe('Next.js 16 Cache Components Integration', () => { let nextProcess: ChildProcess; let redisClient: RedisClientType; let keyPrefix: string; async function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } beforeAll(async () => { // Connect to Redis redisClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379', database: 1, }); await redisClient.connect(); // Generate unique key prefix for this test run keyPrefix = `cache-components-test-${Math.random().toString(36).substring(7)}`; process.env.VERCEL_URL = keyPrefix; // Build and start Next.js app const appDir = path.join( __dirname, '..', 'integration', 'next-app-16-2-3-cache-components', ); console.log('Installing Next.js app dependencies...'); await new Promise((resolve, reject) => { const installProcess = spawn('pnpm', ['install'], { cwd: appDir, stdio: 'inherit', }); installProcess.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`Install failed with code ${code}`)); }); }); console.log('Building Next.js app...'); await new Promise((resolve, reject) => { const buildProcess = spawn('pnpm', ['build'], { cwd: appDir, stdio: 'inherit', }); buildProcess.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`Build failed with code ${code}`)); }); }); console.log('Starting Next.js app...'); nextProcess = spawn('pnpm', ['start', '-p', PORT.toString()], { cwd: appDir, env: { ...process.env, VERCEL_URL: keyPrefix }, }); // Wait for server to be ready await new Promise((resolve) => setTimeout(resolve, 3000)); }, 120000); afterAll(async () => { // Clean up Redis keys const keys = await redisClient.keys(`${keyPrefix}*`); if (keys.length > 0) { await redisClient.del(keys); } await redisClient.quit(); // Kill Next.js process if (nextProcess) { nextProcess.kill(); } }); describe('Basic use cache functionality', () => { it('should cache data and return same counter value on subsequent requests', async () => { // First request const res1 = await fetch(`${BASE_URL}/api/cached-static-fetch`); const data1 = await res1.json(); expect(data1.counter).toBe(1); // Second request should return cached data const res2 = await fetch(`${BASE_URL}/api/cached-static-fetch`); const data2 = await res2.json(); expect(data2.counter).toBe(1); // Same counter value expect(data2.timestamp).toBe(data1.timestamp); // Same timestamp }); it('should store cache entry in Redis', async () => { await fetch(`${BASE_URL}/api/cached-static-fetch`); // Check Redis for cache keys const keys = await redisClient.keys(`${keyPrefix}*`); expect(keys.length).toBeGreaterThan(0); }); }); describe('cacheTag functionality', () => { it('should cache data with tags', async () => { const res1 = await fetch(`${BASE_URL}/api/cached-with-tag`); const data1 = await res1.json(); expect(data1.counter).toBeDefined(); // Second request should return cached data const res2 = await fetch(`${BASE_URL}/api/cached-with-tag`); const data2 = await res2.json(); expect(data2.counter).toBe(data1.counter); }); it('should invalidate cache when tag is revalidated (Stale while revalidate)', async () => { // Get initial cached data const res1 = await fetch(`${BASE_URL}/api/cached-with-tag`); const data1 = await res1.json(); // Revalidate the tag await fetch(`${BASE_URL}/api/revalidate-tag`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tag: 'test-tag' }), }); // The cache should be invalidated - verify by making multiple requests // until we get fresh data (with retries for async revalidation) let freshDataReceived = false; // Next.js tag revalidation can be async and may take longer under some runtimes. // Use a more tolerant window to avoid flaky failures. for (let i = 0; i < 60; i++) { await new Promise((resolve) => setTimeout(resolve, 500)); const res = await fetch(`${BASE_URL}/api/cached-with-tag`); const data = await res.json(); if ( data.counter !== data1.counter || data.timestamp !== data1.timestamp ) { freshDataReceived = true; break; } } expect(freshDataReceived).toBe(true); }, 20_000); }); describe('cacheLife functionality', () => { it('should respect expire window and eventually return refreshed data', async () => { const res1 = await fetch(`${BASE_URL}/api/cached-with-cachelife`); const data1 = await res1.json(); expect(data1.counter).toBe(1); const res2 = await fetch(`${BASE_URL}/api/cached-with-cachelife`); const data2 = await res2.json(); expect(data2.counter).toBe(data1.counter); expect(data2.timestamp).toBe(data1.timestamp); await delay(6500); let refreshedData: any; for (let i = 0; i < 10; i++) { const res = await fetch(`${BASE_URL}/api/cached-with-cachelife`); const data = await res.json(); if ( data.counter !== data1.counter || data.timestamp !== data1.timestamp ) { refreshedData = data; break; } await delay(500); } expect(refreshedData).toBeDefined(); expect(refreshedData.counter).toBeGreaterThan(data1.counter); expect(refreshedData.timestamp).not.toBe(data1.timestamp); }, 20_000); }); describe('Redis cache handler integration', () => { it('should call cache handler get and set methods', async () => { // Make request to trigger cache (don't clear first) await fetch(`${BASE_URL}/api/cached-static-fetch`); // Verify Redis has the cached data const redisKeys = await redisClient.keys(`${keyPrefix}*`); expect(redisKeys.length).toBeGreaterThan(0); // Filter out hash keys (sharedTagsMap) and only check string keys (cache entries) // Try to get each key and verify at least one is a string value let foundStringKey = false; for (const key of redisKeys) { try { const type = await redisClient.type(key); if (type === 'string') { const cachedValue = await redisClient.get(key); if (cachedValue) { foundStringKey = true; expect(cachedValue).toBeTruthy(); break; } } } catch (e) { // Skip non-string keys } } expect(foundStringKey).toBe(true); }); }); });