@safaricom-mxl/nextjs-turbo-redis-cache
Version:
Next.js redis cache handler
388 lines (336 loc) • 10.8 kB
text/typescript
import { createClient } from "redis";
import {
afterEach,
beforeEach,
describe,
expect,
it,
type MockedFunction,
vi,
} from "vitest";
import RedisStringsHandler, { type CacheEntry } from "../RedisStringsHandler";
// Mock the Redis client
vi.mock("redis", () => ({
createClient: vi.fn(() => ({
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
set: vi.fn(),
unlink: vi.fn(),
duplicate: vi.fn(),
on: vi.fn(),
isReady: true,
hScan: vi.fn(),
scan: vi.fn(),
hSet: vi.fn(),
hDel: vi.fn(),
hGet: vi.fn(),
publish: vi.fn(),
subscribe: vi.fn(),
configGet: vi.fn().mockResolvedValue({ "notify-keyspace-events": "Exe" }),
})),
}));
describe("RedisStringsHandler", () => {
let handler: RedisStringsHandler;
let mockClient: any;
beforeEach(() => {
vi.clearAllMocks();
mockClient = {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
set: vi.fn(),
unlink: vi.fn(),
duplicate: vi.fn(),
on: vi.fn(),
isReady: true,
hScan: vi.fn(),
scan: vi.fn(),
hSet: vi.fn(),
hDel: vi.fn(),
hGet: vi.fn(),
publish: vi.fn(),
subscribe: vi.fn(),
configGet: vi.fn().mockResolvedValue({ "notify-keyspace-events": "Exe" }),
};
(createClient as MockedFunction<typeof createClient>).mockReturnValue(
mockClient as any
);
mockClient.duplicate.mockReturnValue({
...mockClient,
connect: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
configGet: vi.fn().mockResolvedValue({ "notify-keyspace-events": "Exe" }),
});
// Mock SyncedMap methods
mockClient.hScan.mockResolvedValue({ cursor: 0, tuples: [] });
mockClient.scan.mockResolvedValue({ cursor: 0, keys: [] });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("constructor", () => {
it("should create a RedisStringsHandler with default options", () => {
handler = new RedisStringsHandler({});
expect(createClient).toHaveBeenCalledWith({
url: "redis://localhost:6379",
pingInterval: 10_000,
database: 1,
});
});
it("should create a RedisStringsHandler with custom options", () => {
handler = new RedisStringsHandler({
redisUrl: "redis://custom:1234",
database: 1,
keyPrefix: "test:",
getTimeoutMs: 1000,
});
expect(createClient).toHaveBeenCalledWith({
url: "redis://custom:1234",
pingInterval: 10_000,
database: 1,
});
});
it("should setup Redis client event handlers", () => {
handler = new RedisStringsHandler({});
expect(mockClient.on).toHaveBeenCalledWith("error", expect.any(Function));
});
});
describe("get", () => {
beforeEach(() => {
handler = new RedisStringsHandler({ keyPrefix: "test:" });
// Mock the waitUntilReady method for SyncedMaps
vi.spyOn(handler.sharedTagsMap, "waitUntilReady").mockResolvedValue();
vi.spyOn(
handler.revalidatedTagsMap,
"waitUntilReady"
).mockResolvedValue();
});
it("should return null when Redis returns null", async () => {
mockClient.get.mockResolvedValue(null);
const result = await handler.get("test-key", {
kind: "APP_ROUTE",
isRoutePPREnabled: false,
isFallback: false,
});
expect(result).toBeNull();
expect(mockClient.get).toHaveBeenCalledWith("test:test-key");
});
it("should return cached entry when found", async () => {
const cacheEntry: CacheEntry = {
value: { test: "data" },
lastModified: Date.now(),
tags: ["tag1", "tag2"],
};
mockClient.get.mockResolvedValue(JSON.stringify(cacheEntry));
const result = await handler.get("test-key", {
kind: "APP_ROUTE",
isRoutePPREnabled: false,
isFallback: false,
});
expect(result).toEqual(cacheEntry);
});
it("should handle FETCH context and validate tags", async () => {
const cacheEntry: CacheEntry = {
value: { test: "data" },
lastModified: Date.now() - 1000,
tags: ["tag1"],
};
mockClient.get.mockResolvedValue(JSON.stringify(cacheEntry));
// Mock revalidatedTagsMap to return a newer timestamp
vi.spyOn(handler.revalidatedTagsMap, "get").mockReturnValue(Date.now());
mockClient.unlink.mockResolvedValue(1);
const result = await handler.get("test-key", {
kind: "FETCH",
revalidate: 60,
fetchUrl: "https://example.com",
fetchIdx: 0,
tags: ["tag1"],
softTags: [],
isFallback: false,
});
expect(result).toBeNull();
expect(mockClient.unlink).toHaveBeenCalledWith("test:test-key");
});
it("should handle errors and return null", async () => {
mockClient.get.mockRejectedValue(new Error("Redis error"));
const result = await handler.get("test-key", {
kind: "APP_ROUTE",
isRoutePPREnabled: false,
isFallback: false,
});
expect(result).toBeNull();
});
});
describe("set", () => {
beforeEach(() => {
handler = new RedisStringsHandler({ keyPrefix: "test:" });
vi.spyOn(handler.sharedTagsMap, "waitUntilReady").mockResolvedValue();
vi.spyOn(
handler.revalidatedTagsMap,
"waitUntilReady"
).mockResolvedValue();
vi.spyOn(handler.sharedTagsMap, "get").mockReturnValue(undefined);
vi.spyOn(handler.sharedTagsMap, "set").mockResolvedValue();
});
it("should set APP_ROUTE data in Redis", async () => {
mockClient.set.mockResolvedValue("OK");
await handler.set(
"test-key",
{
kind: "APP_ROUTE",
status: 200,
headers: {
"cache-control": "public, max-age=60",
"x-nextjs-stale-time": "1234567890",
"x-next-cache-tags": "tag1,tag2",
},
body: Buffer.from("response body"),
},
{
isRoutePPREnabled: false,
isFallback: false,
revalidate: 60,
}
);
expect(mockClient.set).toHaveBeenCalledWith(
"test:test-key",
expect.any(String),
{ EX: expect.any(Number) }
);
});
it("should set APP_PAGE data in Redis", async () => {
mockClient.set.mockResolvedValue("OK");
await handler.set(
"test-key",
{
kind: "APP_PAGE",
status: 200,
headers: {
"x-nextjs-stale-time": "1234567890",
"x-next-cache-tags": "tag1,tag2",
},
html: "<html>content</html>",
rscData: Buffer.from("rsc data"),
segmentData: {},
postboned: null,
},
{
isRoutePPREnabled: false,
isFallback: false,
tags: ["custom-tag"],
}
);
expect(mockClient.set).toHaveBeenCalledWith(
"test:test-key",
expect.any(String),
{ EX: expect.any(Number) }
);
});
it("should set FETCH data in Redis", async () => {
mockClient.set.mockResolvedValue("OK");
await handler.set(
"test-key",
{
kind: "FETCH",
data: {
headers: { "content-type": "application/json" },
body: "base64data",
status: 200,
url: "https://example.com",
},
revalidate: 300,
},
{
isRoutePPREnabled: false,
isFallback: false,
}
);
expect(mockClient.set).toHaveBeenCalledWith(
"test:test-key",
expect.any(String),
{ EX: expect.any(Number) }
);
});
it("should handle errors during set operation", async () => {
mockClient.set.mockRejectedValue(new Error("Redis error"));
await expect(
handler.set(
"test-key",
{
kind: "APP_ROUTE",
status: 200,
headers: {
"x-nextjs-stale-time": "1234567890",
"x-next-cache-tags": "tag1",
},
body: Buffer.from("test"),
},
{
isRoutePPREnabled: false,
isFallback: false,
}
)
).rejects.toThrow("Redis error");
});
});
describe("revalidateTag", () => {
beforeEach(() => {
handler = new RedisStringsHandler({ keyPrefix: "test:" });
vi.spyOn(handler.sharedTagsMap, "waitUntilReady").mockResolvedValue();
vi.spyOn(
handler.revalidatedTagsMap,
"waitUntilReady"
).mockResolvedValue();
vi.spyOn(handler.sharedTagsMap, "entries").mockReturnValue([
["key1", ["tag1", "tag2"]],
["key2", ["tag2", "tag3"]],
["key3", ["tag4"]],
] as any);
vi.spyOn(handler.sharedTagsMap, "delete").mockResolvedValue();
vi.spyOn(handler.revalidatedTagsMap, "set").mockResolvedValue();
vi.spyOn(handler.inMemoryDeduplicationCache, "delete").mockReturnValue();
});
it("should revalidate single tag", async () => {
mockClient.unlink.mockResolvedValue(2);
await handler.revalidateTag("tag1");
expect(mockClient.unlink).toHaveBeenCalledWith(["test:key1"]);
});
it("should revalidate multiple tags", async () => {
mockClient.unlink.mockResolvedValue(2);
await handler.revalidateTag(["tag1", "tag2"]);
expect(mockClient.unlink).toHaveBeenCalledWith([
"test:key1",
"test:key2",
]);
});
it("should handle implicit tags (Next.js internal tags)", async () => {
await handler.revalidateTag("_N_T_/some-route");
expect(handler.revalidatedTagsMap.set).toHaveBeenCalledWith(
"_N_T_/some-route",
expect.any(Number)
);
});
it("should do nothing when no keys match the tag", async () => {
await handler.revalidateTag("non-existent-tag");
expect(mockClient.unlink).not.toHaveBeenCalled();
});
it("should handle errors during revalidation", async () => {
mockClient.unlink.mockRejectedValue(new Error("Redis error"));
await expect(handler.revalidateTag("tag1")).rejects.toThrow(
"Redis error"
);
});
});
describe("resetRequestCache", () => {
beforeEach(() => {
handler = new RedisStringsHandler({});
});
it("should be a no-op method", () => {
expect(() => handler.resetRequestCache()).not.toThrow();
});
});
});