@safaricom-mxl/nextjs-turbo-redis-cache
Version:
Next.js redis cache handler
861 lines (756 loc) • 34.6 kB
text/typescript
import { spawn } from "node:child_process";
import { join } from "node:path";
import fetch from "node-fetch";
import { createClient } from "redis";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { CacheEntry } from "../../src/RedisStringsHandler";
import { revalidate as next1503RevalidatedFetchRoute } from "./next-app-15-0-3/src/app/api/revalidated-fetch/route";
// const NEXT_APP_DIR = join(__dirname, 'next-app-15-0-3');
const NEXT_APP_DIR = join(__dirname, "next-app-15-3-2");
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;
let redisClient;
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 = 20_000) {
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.NODE_ENV = "production";
process.env.MXL_URL = `integration-test-${Math.random().toString(36).substring(2, 15)}`;
console.log("redis key prefix is:", process.env.MXL_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.MXL_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.close();
}
});
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.MXL_URL}*`);
expect(keys.length).toBeGreaterThan(0);
// check the content of redis key
const value = await redisClient.get(
`${process.env.MXL_URL}/api/cached-static-fetch`
);
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.MXL_URL}/api/cached-static-fetch`
);
expect(keys.length).toBe(0);
const hashmap = await redisClient.hGet(
`${process.env.MXL_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.MXL_URL}/api/cached-static-fetch`
);
expect(keys.length).toBe(1);
const hashmap = await redisClient.hGet(
`${process.env.MXL_URL}__sharedTags__`,
"/api/cached-static-fetch"
);
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 FirstDelay = 6000;
const SecondDelay = 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(FirstDelay);
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(SecondDelay);
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.MXL_URL}/api/revalidated-fetch`
);
expect(ttl).toBeLessThan(2 * next1503RevalidatedFetchRoute);
expect(ttl).toBeGreaterThan(
2 * next1503RevalidatedFetchRoute -
FirstDelay -
SecondDelay -
REDIS_BACKGROUND_SYNC_DELAY
);
await delay(ttl * 1000 + 500);
// check Redis keys
const keys = await redisClient.keys(
`${process.env.MXL_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.MXL_URL}__sharedTags__`,
"/api/revalidated-fetch"
);
expect(hashmap).toBeNull();
}, 15_000);
}
});
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.MXL_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(data1.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.MXL_URL +
"/api/nested-fetch-in-api-route/revalidated-fetch"
);
expect(keys.length).toBe(0);
const hashmap = await redisClient.hGet(
`${process.env.MXL_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.MXL_URL + cacheEntryKey
);
expect(keys.length).toBe(1);
const hashmap = await redisClient.hGet(
`${process.env.MXL_URL}__sharedTags__`,
cacheEntryKey
);
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.MXL_URL +
"/api/nested-fetch-in-api-route/revalidated-fetch"
);
expect(keys.length).toBe(0);
const hashmap = await redisClient.hGet(
`${process.env.MXL_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.MXL_URL + cacheEntryKey
);
expect(keys.length).toBe(1);
const hashmap = await redisClient.hGet(
`${process.env.MXL_URL}__sharedTags__`,
cacheEntryKey
);
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 () => {
// check Redis keys
const ttl = await redisClient.ttl(
`${process.env.MXL_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);
});
it("The data in the redis key should match the expected format", async () => {
const data = await redisClient.get(
`${process.env.MXL_URL}/pages/no-fetch/default-page`
);
expect(data).toBeDefined();
const cacheEntry: CacheEntry = JSON.parse(data);
// The format should be as expected
expect(cacheEntry).toEqual({
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",
},
status: 200,
},
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(3000);
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.MXL_URL}/pages/no-fetch/default-page`
);
expect(keys.length).toBe(0);
const hashmap = await redisClient.hGet(
`${process.env.MXL_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.MXL_URL}/pages/no-fetch/default-page`
);
expect(keys.length).toBe(1);
const hashmap = await redisClient.hGet(
`${process.env.MXL_URL}__sharedTags__`,
"/pages/no-fetch/default-page"
);
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.MXL_URL +
"/pages/revalidated-fetch/revalidate15--default-page"
);
expect(keys1.length).toBe(1);
const keys2 = await redisClient.keys(
process.env.MXL_URL +
"e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4"
);
expect(keys2.length).toBe(1);
const keys3 = await redisClient.keys(
`${process.env.MXL_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.MXL_URL}__sharedTags__`,
"/pages/revalidated-fetch/revalidate15--default-page"
);
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.MXL_URL}__sharedTags__`,
"e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4"
);
expect(JSON.parse(hashmap2)).toEqual([
"revalidated-fetch-revalidate15-default-page",
]);
const hashmap3 = await redisClient.hGet(
`${process.env.MXL_URL}__sharedTags__`,
"/api/revalidated-fetch"
);
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.MXL_URL +
"/pages/revalidated-fetch/revalidate15--default-page"
);
expect(keys1.length).toBe(0);
const hashmap1 = await redisClient.hGet(
`${process.env.MXL_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.MXL_URL +
"e978cf5ddb8bf799209e828635cfe9ae6862f6735cea97f01ab752ff6fa489b4"
);
expect(keys2.length).toBe(1);
const hashmap2 = await redisClient.hGet(
`${process.env.MXL_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.MXL_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.MXL_URL}/api/revalidated-fetch`
);
expect(keys3.length).toBe(1);
const hashmap3 = await redisClient.hGet(
`${process.env.MXL_URL}__sharedTags__`,
"/api/revalidated-fetch"
);
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
// });
});
});