@trieb.work/nextjs-turbo-redis-cache
Version:
Designed for speed, scalability, and optimized performance, nextjs-turbo-redis-cache is your custom cache handler for demanding production environments.
921 lines (807 loc) • 37.6 kB
text/typescript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import fetch from 'node-fetch';
import { createClient, RedisClientType } from 'redis';
import { join } from 'path';
import { CacheEntry } from '../../src/RedisStringsHandler';
import { revalidate as next1503_revalidatedFetch_route } from './next-app-15-0-3/src/app/api/revalidated-fetch/route';
// Select which Next.js test app to use. Can be overridden via NEXT_TEST_APP env var
// Examples: next-app-15-0-3, next-app-15-3-2, next-app-15-4-7
const NEXT_TEST_APP = process.env.NEXT_TEST_APP || 'next-app-15-4-7';
const NEXT_APP_DIR = join(__dirname, NEXT_TEST_APP);
console.log('NEXT_APP_DIR', NEXT_APP_DIR);
const NEXT_START_PORT = 3055;
const NEXT_START_URL = `http://localhost:${NEXT_START_PORT}`;
const REDIS_BACKGROUND_SYNC_DELAY = 250; //ms delay to prevent flaky tests in slow CI environments
let nextProcess: ChildProcessWithoutNullStreams;
let redisClient: RedisClientType;
async function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function runCommand(cmd: string, args: string[], cwd: string) {
return new Promise((resolve, reject) => {
let stderr = '';
let stdout = '';
const proc = spawn(cmd, args, { cwd, stdio: 'pipe' });
proc.stdout.on('data', (data) => {
if (process.env.DEBUG_INTEGRATION) {
console.log(data.toString());
}
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
if (process.env.DEBUG_INTEGRATION) {
console.error(data.toString());
}
stderr += data.toString();
});
proc.on('exit', (code) => {
if (code === 0) resolve(undefined);
else {
reject(
new Error(
`${cmd} ${args.join(' ')} failed with code ${code}\n` +
`stdout: ${stdout}\n` +
`stderr: ${stderr}`,
),
);
}
});
});
}
async function waitForServer(url, timeout = 20000) {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const res = await fetch(url + '/api/cached-static-fetch');
if (res.ok) return;
} catch {}
await new Promise((r) => setTimeout(r, 300));
}
throw new Error('Next.js server did not start in time');
}
describe('Next.js Turbo Redis Cache Integration', () => {
beforeAll(async () => {
// If there was detected to run a server before (any old server which was not stopped correctly), kill it
try {
const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
if (res.ok) {
await runCommand('pkill', ['next'], NEXT_APP_DIR);
}
} catch {}
// Set up environment variables
process.env.VERCEL_ENV = 'production';
process.env.VERCEL_URL =
'integration-test-' + Math.random().toString(36).substring(2, 15);
console.log('redis key prefix is:', process.env.VERCEL_URL);
// Only override if redis env vars if not set. This can be set in the CI env.
process.env.REDISHOST = process.env.REDISHOST || 'localhost';
process.env.REDISPORT = process.env.REDISPORT || '6379';
process.env.NEXT_START_PORT = String(NEXT_START_PORT);
if (process.env.SKIP_BUILD === 'true') {
console.log('skipping build');
} else {
// Build Next.js app first
await runCommand('pnpm', ['i'], NEXT_APP_DIR);
console.log('pnpm i done');
await runCommand('pnpm', ['build'], NEXT_APP_DIR);
console.log('pnpm build done');
}
// Start Next.js app
nextProcess = spawn(
'npx',
['next', 'start', '-p', String(NEXT_START_PORT)],
{
cwd: NEXT_APP_DIR,
env: {
...process.env,
},
stdio: 'pipe',
},
);
if (process.env.DEBUG_INTEGRATION) {
nextProcess.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
}
nextProcess.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
await waitForServer(NEXT_START_URL);
console.log('next start successful');
// Connect to Redis
redisClient = createClient({
url: `redis://${process.env.REDISHOST}:${process.env.REDISPORT}`,
});
await redisClient.connect();
console.log('redis key prefix is:', process.env.VERCEL_URL);
}, 60_000);
afterAll(async () => {
if (process.env.KEEP_SERVER_RUNNING === 'true') {
console.log('keeping server running');
} else {
if (nextProcess) nextProcess.kill();
}
if (redisClient) await redisClient.quit();
});
describe('should have the correct caching behavior for API routes', () => {
describe('should cache static API routes in Redis', () => {
let counter1: number;
it('First request (should increment counter)', async () => {
const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
const data: any = await res.json();
expect(data.counter).toBe(1);
counter1 = data.counter;
});
it('Second request (should hit cache, counter should not increment if cache works)', async () => {
const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
const data: any = await res.json();
// If cache is working, counter should stay 1; if not, it will increment
expect(data.counter).toBe(counter1);
});
it('The data in the redis key should match the expected format', async () => {
await delay(REDIS_BACKGROUND_SYNC_DELAY);
const keys = await redisClient.keys(process.env.VERCEL_URL + '*');
expect(keys.length).toBeGreaterThan(0);
// check the content of redis key
const value = (await redisClient.get(
process.env.VERCEL_URL + '/api/cached-static-fetch',
)) as string;
expect(value).toBeDefined();
const cacheEntry: CacheEntry = JSON.parse(value);
// The format should be as expected
expect(cacheEntry).toEqual({
value: {
kind: 'APP_ROUTE',
status: 200,
body: { $binary: 'eyJjb3VudGVyIjoxfQ==' },
headers: {
'cache-control': 'public, max-age=1',
'content-type': 'application/json',
'x-next-cache-tags':
'_N_T_/layout,_N_T_/api/layout,_N_T_/api/cached-static-fetch/layout,_N_T_/api/cached-static-fetch/route,_N_T_/api/cached-static-fetch',
},
},
lastModified: expect.any(Number),
tags: [
'_N_T_/layout',
'_N_T_/api/layout',
'_N_T_/api/cached-static-fetch/layout',
'_N_T_/api/cached-static-fetch/route',
'_N_T_/api/cached-static-fetch',
],
});
expect((cacheEntry.value as any).kind).toBe('APP_ROUTE');
const bodyBuffer = Buffer.from(
(cacheEntry.value as any)?.body?.$binary,
'base64',
);
const bodyJson = JSON.parse(bodyBuffer.toString('utf-8'));
expect(bodyJson.counter).toBe(counter1);
});
it('A request to revalidatePath API should remove the route from redis (string and hashmap)', async () => {
const revalidateRes = await fetch(
NEXT_START_URL + '/api/revalidatePath?path=/api/cached-static-fetch',
);
const revalidateResJson: any = await revalidateRes.json();
expect(revalidateResJson.success).toBe(true);
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// check Redis keys
const keys = await redisClient.keys(
process.env.VERCEL_URL + '/api/cached-static-fetch',
);
expect(keys.length).toBe(0);
const hashmap = await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/api/cached-static-fetch',
);
expect(hashmap).toBeNull();
});
it('A new request after the revalidation should increment the counter (because the route was re-evaluated)', async () => {
const res = await fetch(NEXT_START_URL + '/api/cached-static-fetch');
const data: any = await res.json();
expect(data.counter).toBe(counter1 + 1);
});
it('After the new request was made the redis key and hashmap should be set again', async () => {
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// check Redis keys
const keys = await redisClient.keys(
process.env.VERCEL_URL + '/api/cached-static-fetch',
);
expect(keys.length).toBe(1);
const hashmap = (await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/api/cached-static-fetch',
)) as string;
expect(JSON.parse(hashmap)).toEqual([
'_N_T_/layout',
'_N_T_/api/layout',
'_N_T_/api/cached-static-fetch/layout',
'_N_T_/api/cached-static-fetch/route',
'_N_T_/api/cached-static-fetch',
]);
});
});
describe('should cache revalidation API routes in Redis', () => {
let counter1: number;
it('First request (should increment counter)', async () => {
const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
const data: any = await res.json();
expect(data.counter).toBe(1);
counter1 = data.counter;
});
it('Second request which is send in revalidation time should hit cache (counter should not increment)', async () => {
const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
const data: any = await res.json();
expect(data.counter).toBe(counter1);
});
if (process.env.SKIP_OPTIONAL_LONG_RUNNER_TESTS !== 'true') {
const FIRST_DELAY = 6000;
const SECOND_DELAY = 1000;
it('Third request which is send directly after revalidation time will still serve cache but trigger re-evaluation (stale-while-revalidate)', async () => {
await delay(FIRST_DELAY);
const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
const data: any = await res.json();
expect(data.counter).toBe(counter1);
}, 10_000);
it('Third request which is send directly after revalidation time will serve re-evaluated data (stale-while-revalidate)', async () => {
await delay(SECOND_DELAY);
const res = await fetch(NEXT_START_URL + '/api/revalidated-fetch');
const data: any = await res.json();
expect(data.counter).toBe(counter1 + 1);
});
it('After expiration, the redis key should be removed from redis and the hashmap', async () => {
const ttl = await redisClient.ttl(
process.env.VERCEL_URL + '/api/revalidated-fetch',
);
expect(ttl).toBeLessThan(2 * next1503_revalidatedFetch_route);
expect(ttl).toBeGreaterThan(
2 * next1503_revalidatedFetch_route -
FIRST_DELAY -
SECOND_DELAY -
REDIS_BACKGROUND_SYNC_DELAY,
);
await delay(ttl * 1000 + 500);
// check Redis keys
const keys = await redisClient.keys(
process.env.VERCEL_URL + '/api/revalidated-fetch',
);
expect(keys.length).toBe(0);
await delay(1000);
// The key should also be removed from the hashmap
const hashmap = await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/api/revalidated-fetch',
);
expect(hashmap).toBeNull();
}, 15_000);
}
});
// Next 16-only caching API tests. These routes exist only in the Next 16 test app
// and exercise the new revalidateTag profiles and updateTag semantics.
if (NEXT_TEST_APP.includes('16.')) {
describe('Next 16 caching APIs', () => {
const cachedStaticPath = '/api/cached-static-fetch';
async function assertCachedStaticFetchCleared() {
await delay(REDIS_BACKGROUND_SYNC_DELAY);
const keys = await redisClient.keys(
process.env.VERCEL_URL + cachedStaticPath,
);
expect(keys.length).toBe(0);
const hashmap = await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
cachedStaticPath,
);
expect(hashmap).toBeNull();
}
it('revalidateTag(tag, "max") should invalidate cached-static-fetch by tag', async () => {
// Warm up cache and sharedTagsMap for cached-static-fetch
await fetch(NEXT_START_URL + cachedStaticPath);
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// Use explicit tag for this route as set by Next.js
const tag = '_N_T_/api/cached-static-fetch';
const res = await fetch(
`${NEXT_START_URL}/api/revalidateTag?tag=${encodeURIComponent(
tag,
)}&profile=max`,
);
const json: any = await res.json();
expect(json.success).toBe(true);
await assertCachedStaticFetchCleared();
});
it('revalidateTag(tag, { expire: 60 }) should also invalidate cached-static-fetch', async () => {
// Warm up cache again
await fetch(NEXT_START_URL + cachedStaticPath);
await delay(REDIS_BACKGROUND_SYNC_DELAY);
const tag = '_N_T_/api/cached-static-fetch';
const res = await fetch(
`${NEXT_START_URL}/api/revalidateTag?tag=${encodeURIComponent(
tag,
)}&profile=expire`,
);
const json: any = await res.json();
expect(json.success).toBe(true);
await assertCachedStaticFetchCleared();
});
});
}
describe('should not cache uncached API routes in Redis', () => {
let counter1: number;
it('First request should increment counter', async () => {
const res1 = await fetch(NEXT_START_URL + '/api/uncached-fetch');
const data1: any = await res1.json();
expect(data1.counter).toBe(1);
counter1 = data1.counter;
});
it('Second request should hit cache (counter should not increment if cache works)', async () => {
const res2 = await fetch(NEXT_START_URL + '/api/uncached-fetch');
const data2: any = await res2.json();
// If not caching it is working request 2 should be higher as request one
expect(data2.counter).toBe(counter1 + 1);
});
it('The redis key should not be set', async () => {
// check the content of redis key
const value = await redisClient.get(
process.env.VERCEL_URL + '/api/uncached-fetch',
);
expect(value).toBeNull();
});
});
describe('should cache a nested fetch request inside a uncached API route', () => {
describe('should cache the nested fetch request (but not the API route itself)', () => {
let counter: number;
let subCounter: number;
it('should deduplicate requests to the sub-fetch-request, but not to the API route itself', async () => {
// make two requests, both should return the same subFetchData but different counter
const res1 = await fetch(
NEXT_START_URL + '/api/nested-fetch-in-api-route/revalidated-fetch',
);
const res2 = await fetch(
NEXT_START_URL + '/api/nested-fetch-in-api-route/revalidated-fetch',
);
const [data1, data2]: any[] = await Promise.all([
res1.json(),
res2.json(),
]);
// API route counter itself increments for each request
// But we do not know which request is first and which is second
if (data1.counter < data2.counter) {
expect(data2.counter).toBeGreaterThan(data1.counter);
counter = data2.counter;
} else {
expect(data1.counter).toBeGreaterThan(data2.counter);
counter = data1.counter;
}
// API route counter of revalidated sub-fetch-request should be the same (request deduplication of fetch requests)
expect(data1.subFetchData.counter).toBe(data2.subFetchData.counter);
subCounter = data1.subFetchData.counter;
});
if (process.env.SKIP_OPTIONAL_LONG_RUNNER_TESTS !== 'true') {
it('should return the same subFetchData after 2 seconds (regular caching within revalidation interval (=3s) works)', async () => {
// make another request after 2 seconds, it should return the same subFetchData
await delay(2000); // 2s < 3s (revalidate interval)
const res = await fetch(
NEXT_START_URL +
'/api/nested-fetch-in-api-route/revalidated-fetch',
);
const data: any = await res.json();
expect(data.counter).toBe(counter + 1);
expect(data.subFetchData.counter).toBe(subCounter);
});
it('should return the same subFetchData after 2 seconds and new data after another 2 seconds (caching while revalidation works)', async () => {
// make another request after another 2 seconds, it should return the same subFetchData (caching while revalidation works)
await delay(2000); // 2s+2s < 3s*2 (=TTL = revalidate=3s*2)
const res1 = await fetch(
NEXT_START_URL +
'/api/nested-fetch-in-api-route/revalidated-fetch',
);
const data1: any = await res1.json();
expect(data1.counter).toBe(counter + 2);
expect(data1.subFetchData.counter).toBe(subCounter);
// make another request directly after first request which was still in TTL, it should return new data (caching while revalidation works)
await delay(REDIS_BACKGROUND_SYNC_DELAY);
const res2 = await fetch(
NEXT_START_URL +
'/api/nested-fetch-in-api-route/revalidated-fetch',
);
const data2: any = await res2.json();
expect(data2.counter).toBe(counter + 3);
expect(data2.subFetchData.counter).toBe(subCounter + 1);
});
it('A request to revalidatePage API should remove the route from redis (string and hashmap)', async () => {
const revalidateRes = await fetch(
NEXT_START_URL +
'/api/revalidatePath?path=/api/nested-fetch-in-api-route/revalidated-fetch',
);
const revalidateResJson: any = await revalidateRes.json();
expect(revalidateResJson.success).toBe(true);
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// check Redis keys
const keys = await redisClient.keys(
process.env.VERCEL_URL +
'/api/nested-fetch-in-api-route/revalidated-fetch',
);
expect(keys.length).toBe(0);
const hashmap = await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/api/nested-fetch-in-api-route/revalidated-fetch',
);
expect(hashmap).toBeNull();
});
it('A new request after the revalidation should increment the counter (because the route was re-evaluated)', async () => {
const res = await fetch(
NEXT_START_URL +
'/api/nested-fetch-in-api-route/revalidated-fetch',
);
const data: any = await res.json();
expect(data.counter).toBe(counter + 4);
expect(data.subFetchData.counter).toBe(subCounter + 2);
});
it('After the new request was made the redis key and hashmap should be set again', async () => {
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// This cache entry key is the key of the sub-fetch-request, it will be generated by nextjs based on the headers/payload etc.
// So it should stay the same unless nextjs will change something in there implementation
const cacheEntryKey =
'094a786b7ad391852168d3a7bcf75736777697d24a856a0089837f4b7de921df';
// check Redis keys
const keys = await redisClient.keys(
process.env.VERCEL_URL + cacheEntryKey,
);
expect(keys.length).toBe(1);
const hashmap = (await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
cacheEntryKey,
)) as string;
expect(JSON.parse(hashmap)).toEqual([
'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
]);
});
it('A request to revalidateTag API should remove the route from redis (string and hashmap)', async () => {
const revalidateRes = await fetch(
NEXT_START_URL +
'/api/revalidateTag?tag=revalidated-fetch-revalidate3-nested-fetch-in-api-route',
);
const revalidateResJson: any = await revalidateRes.json();
expect(revalidateResJson.success).toBe(true);
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// check Redis keys
const keys = await redisClient.keys(
process.env.VERCEL_URL +
'/api/nested-fetch-in-api-route/revalidated-fetch',
);
expect(keys.length).toBe(0);
const hashmap = await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/api/nested-fetch-in-api-route/revalidated-fetch',
);
expect(hashmap).toBeNull();
});
it('Another new request after the revalidation should increment the counter (because the route was re-evaluated)', async () => {
const res = await fetch(
NEXT_START_URL +
'/api/nested-fetch-in-api-route/revalidated-fetch',
);
const data: any = await res.json();
expect(data.counter).toBe(counter + 5);
expect(data.subFetchData.counter).toBe(subCounter + 3);
});
it('After the new request was made the redis key and hashmap should be set again', async () => {
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// This cache entry key is the key of the sub-fetch-request, it will be generated by nextjs based on the headers/payload etc.
// So it should stay the same unless nextjs will change something in there implementation
const cacheEntryKey =
'094a786b7ad391852168d3a7bcf75736777697d24a856a0089837f4b7de921df';
// check Redis keys
const keys = await redisClient.keys(
process.env.VERCEL_URL + cacheEntryKey,
);
expect(keys.length).toBe(1);
const hashmap = (await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
cacheEntryKey,
)) as string;
expect(JSON.parse(hashmap)).toEqual([
'revalidated-fetch-revalidate3-nested-fetch-in-api-route',
]);
});
}
});
});
// describe('With a API route that has a unstable_cacheTag', () => {
// // TODO: implement API route for this test as well as the test itself
// });
// describe('With a API route that has a unstable_cacheLife', () => {
// // TODO: implement API route for this test as well as the test itself
// });
});
describe('should have the correct caching behavior for pages', () => {
describe('Without any fetch requests inside the page', () => {
describe('With default page configuration for revalidate and dynamic values', () => {
let timestamp1: string | undefined;
it('Two parallel requests should return the same timestamp (because requests are deduplicated)', async () => {
// First request (should increment counter)
const [pageRes1, pageRes2] = await Promise.all([
fetch(NEXT_START_URL + '/pages/no-fetch/default-page'),
fetch(NEXT_START_URL + '/pages/no-fetch/default-page'),
]);
const pageText1 = await pageRes1.text();
timestamp1 = pageText1.match(/Timestamp: <!-- -->(\d+)/)?.[1];
expect(timestamp1).toBeDefined();
const pageText2 = await pageRes2.text();
const timestamp2 = pageText2.match(/Timestamp: <!-- -->(\d+)/)?.[1];
expect(timestamp2).toBeDefined();
expect(timestamp1).toBe(timestamp2);
});
it('Redis should have a key for the page which should have a TTL set to 28 days (2 * 14 days default revalidate time)', async () => {
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// check Redis keys
const ttl = await redisClient.ttl(
process.env.VERCEL_URL + '/pages/no-fetch/default-page',
);
// 14 days is default revalidate for pages -> expiration time is 2 * revalidate time -> -10 seconds for testing offset stability
expect(ttl).toBeGreaterThan(2 * 14 * 24 * 60 * 60 - 30);
expect(ttl).toBeLessThanOrEqual(2 * 14 * 24 * 60 * 60);
});
it('The data in the redis key should match the expected format', async () => {
const data = (await redisClient.get(
process.env.VERCEL_URL + '/pages/no-fetch/default-page',
)) as string;
expect(data).toBeDefined();
const cacheEntry: CacheEntry = JSON.parse(data);
// The format should be as expected. We intentionally do not assert on an optional status field here
// so that different Next.js versions (which may include or omit it) are both supported.
expect(cacheEntry).toMatchObject({
value: {
kind: 'APP_PAGE',
html: expect.any(String),
rscData: {
$binary: expect.any(String),
},
headers: {
'x-nextjs-stale-time': expect.any(String),
'x-next-cache-tags':
'_N_T_/layout,_N_T_/pages/layout,_N_T_/pages/no-fetch/layout,_N_T_/pages/no-fetch/default-page/layout,_N_T_/pages/no-fetch/default-page/page,_N_T_/pages/no-fetch/default-page',
},
},
lastModified: expect.any(Number),
tags: [
'_N_T_/layout',
'_N_T_/pages/layout',
'_N_T_/pages/no-fetch/layout',
'_N_T_/pages/no-fetch/default-page/layout',
'_N_T_/pages/no-fetch/default-page/page',
'_N_T_/pages/no-fetch/default-page',
],
});
});
if (process.env.SKIP_OPTIONAL_LONG_RUNNER_TESTS !== 'true') {
it('A new request after 3 seconds should return the same timestamp (because the page was cached in in-memory cache)', async () => {
await delay(3_000);
const pageRes3 = await fetch(
NEXT_START_URL + '/pages/no-fetch/default-page',
);
const pageText3 = await pageRes3.text();
const timestamp3 = pageText3.match(/Timestamp: <!-- -->(\d+)/)?.[1];
expect(timestamp3).toBeDefined();
expect(timestamp1).toBe(timestamp3);
});
it('A new request after 11 seconds should return the same timestamp (because the page was cached in redis cache)', async () => {
await delay(11_000);
const pageRes4 = await fetch(
NEXT_START_URL + '/pages/no-fetch/default-page',
);
const pageText4 = await pageRes4.text();
const timestamp4 = pageText4.match(/Timestamp: <!-- -->(\d+)/)?.[1];
expect(timestamp4).toBeDefined();
expect(timestamp1).toBe(timestamp4);
}, 15_000);
}
it('A request to revalidatePage API should remove the page from redis (string and hashmap)', async () => {
const revalidateRes = await fetch(
NEXT_START_URL +
'/api/revalidatePath?path=/pages/no-fetch/default-page',
);
const revalidateResJson: any = await revalidateRes.json();
expect(revalidateResJson.success).toBe(true);
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// check Redis keys
const keys = await redisClient.keys(
process.env.VERCEL_URL + '/pages/no-fetch/default-page',
);
expect(keys.length).toBe(0);
const hashmap = await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/pages/no-fetch/default-page',
);
expect(hashmap).toBeNull();
});
it('A new request after the revalidation should return a new timestamp (because the page was recreated)', async () => {
const pageRes4 = await fetch(
NEXT_START_URL + '/pages/no-fetch/default-page',
);
const pageText4 = await pageRes4.text();
const timestamp4 = pageText4.match(/Timestamp: <!-- -->(\d+)/)?.[1];
expect(timestamp4).toBeDefined();
expect(Number(timestamp4)).toBeGreaterThan(Number(timestamp1));
});
it('After the new request was made the redis key and hashmap should be set again', async () => {
// check Redis keys
await delay(REDIS_BACKGROUND_SYNC_DELAY);
const keys = await redisClient.keys(
process.env.VERCEL_URL + '/pages/no-fetch/default-page',
);
expect(keys.length).toBe(1);
const hashmap = (await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/pages/no-fetch/default-page',
)) as string;
expect(JSON.parse(hashmap)).toEqual([
'_N_T_/layout',
'_N_T_/pages/layout',
'_N_T_/pages/no-fetch/layout',
'_N_T_/pages/no-fetch/default-page/layout',
'_N_T_/pages/no-fetch/default-page/page',
'_N_T_/pages/no-fetch/default-page',
]);
});
});
});
// describe('With a cached static fetch request inside a page', () => {
// // TODO: implement test for `test/integration/next-app/src/app/pages/cached-static-fetch`
// });
describe('With a cached revalidation fetch request inside a page', () => {
let firstTimestamp: string;
let firstCounter: string;
it('should set all cache entries for this page after request is finished', async () => {
const pageRes = await fetch(
NEXT_START_URL +
'/pages/revalidated-fetch/revalidate15--default-page',
);
const pageText = await pageRes.text();
const timestamp = pageText.match(/Timestamp: <!-- -->(\d+)/)?.[1];
const counter = pageText.match(/Counter: <!-- -->(\d+)/)?.[1];
expect(timestamp).toBeDefined();
expect(counter).toBeDefined();
firstTimestamp = timestamp!;
firstCounter = counter!;
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// test cache entry for 3 keys are set
const keys1 = await redisClient.keys(
process.env.VERCEL_URL +
'/pages/revalidated-fetch/revalidate15--default-page',
);
expect(keys1.length).toBe(1);
const keys2 = await redisClient.keys(
process.env.VERCEL_URL +
'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
);
expect(keys2.length).toBe(1);
const keys3 = await redisClient.keys(
process.env.VERCEL_URL + '/api/revalidated-fetch',
);
expect(keys3.length).toBe(1);
// test shared tag hashmap to be set for all keys
const hashmap1 = (await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/pages/revalidated-fetch/revalidate15--default-page',
)) as string;
expect(JSON.parse(hashmap1)).toEqual([
'_N_T_/layout',
'_N_T_/pages/layout',
'_N_T_/pages/revalidated-fetch/layout',
'_N_T_/pages/revalidated-fetch/revalidate15--default-page/layout',
'_N_T_/pages/revalidated-fetch/revalidate15--default-page/page',
'_N_T_/pages/revalidated-fetch/revalidate15--default-page',
'revalidated-fetch-revalidate15-default-page',
]);
const hashmap2 = (await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
)) as string;
expect(JSON.parse(hashmap2)).toEqual([
'revalidated-fetch-revalidate15-default-page',
]);
const hashmap3 = (await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/api/revalidated-fetch',
)) as string;
expect(JSON.parse(hashmap3)).toEqual([
'_N_T_/layout',
'_N_T_/api/layout',
'_N_T_/api/revalidated-fetch/layout',
'_N_T_/api/revalidated-fetch/route',
'_N_T_/api/revalidated-fetch',
]);
});
it('a new request should return the same timestamp as the first request', async () => {
const pageRes = await fetch(
NEXT_START_URL +
'/pages/revalidated-fetch/revalidate15--default-page',
);
const pageText = await pageRes.text();
const timestamp = pageText.match(/Timestamp: <!-- -->(\d+)/)?.[1];
expect(timestamp).toBeDefined();
expect(timestamp).toBe(firstTimestamp);
});
it('A request to revalidatePath API should remove the page from redis (string and hashmap) but not the api route', async () => {
const revalidateRes = await fetch(
NEXT_START_URL +
'/api/revalidatePath?path=/pages/revalidated-fetch/revalidate15--default-page',
);
const revalidateResJson: any = await revalidateRes.json();
expect(revalidateResJson.success).toBe(true);
await delay(REDIS_BACKGROUND_SYNC_DELAY);
// test no cache entry for 2 keys
const keys1 = await redisClient.keys(
process.env.VERCEL_URL +
'/pages/revalidated-fetch/revalidate15--default-page',
);
expect(keys1.length).toBe(0);
const hashmap1 = await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/pages/revalidated-fetch/revalidate15--default-page',
);
expect(hashmap1).toBeNull();
// sub-fetch-request is not removed directly but will be removed on next get request
const keys2 = await redisClient.keys(
process.env.VERCEL_URL +
'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
);
expect(keys2.length).toBe(1);
const hashmap2 = await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4',
);
expect(hashmap2).toBeDefined();
// the page should also be in revalidatedTagsMap so that the nested fetch requests knows that the page was invalidated
const revalidationTimestamp = await redisClient.hGet(
process.env.VERCEL_URL + '__revalidated_tags__',
'_N_T_/pages/revalidated-fetch/revalidate15--default-page',
);
const ts = Number(revalidationTimestamp);
expect(ts).toBeGreaterThan(1);
expect(ts).toBeLessThan(Number(Date.now()));
// API route should still be cached
const keys3 = await redisClient.keys(
process.env.VERCEL_URL + '/api/revalidated-fetch',
);
expect(keys3.length).toBe(1);
const hashmap3 = (await redisClient.hGet(
process.env.VERCEL_URL + '__sharedTags__',
'/api/revalidated-fetch',
)) as string;
expect(JSON.parse(hashmap3)).toEqual([
'_N_T_/layout',
'_N_T_/api/layout',
'_N_T_/api/revalidated-fetch/layout',
'_N_T_/api/revalidated-fetch/route',
'_N_T_/api/revalidated-fetch',
]);
});
it('a new request should return a newer timestamp as the first request (which was invalidated by revalidatePath)', async () => {
const pageRes = await fetch(
NEXT_START_URL +
'/pages/revalidated-fetch/revalidate15--default-page',
);
const pageText = await pageRes.text();
const timestamp = pageText.match(/Timestamp: <!-- -->(\d+)/)?.[1];
const secondCounter = pageText.match(/Counter: <!-- -->(\d+)/)?.[1];
expect(timestamp).toBeDefined();
expect(Number(timestamp)).toBeGreaterThan(Number(firstTimestamp));
//but the new request should not have a higher counter than the first request (because the cache of the API route should not be invalidated)
expect(secondCounter).toBeDefined();
expect(secondCounter).toBe(firstCounter);
});
});
// describe('With a uncached fetch request inside a page', () => {
// // TODO: implement test for `test/integration/next-app/src/app/pages/uncached-fetch`
//
// });
// describe('With a page that has a unstable_cacheTag', () => {
// // TODO: implement page for this test as well as the test itself
// });
// describe('With a page that has a unstable_cacheLife', () => {
// // TODO: implement page for this test as well as the test itself
// });
});
});