@safaricom-mxl/nextjs-turbo-redis-cache
Version:
Next.js redis cache handler
354 lines (287 loc) • 10.1 kB
text/typescript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CachedHandler, { resetCachedHandler } from "../CachedHandler";
import RedisStringsHandler from "../RedisStringsHandler";
// Mock the RedisStringsHandler
vi.mock("../RedisStringsHandler", () => ({
default: vi.fn().mockImplementation(() => ({
get: vi.fn(),
set: vi.fn(),
revalidateTag: vi.fn(),
resetRequestCache: vi.fn(),
})),
}));
describe("CachedHandler", () => {
let handler: CachedHandler;
let mockRedisHandler: any;
beforeEach(() => {
vi.clearAllMocks();
resetCachedHandler(); // Reset the singleton
mockRedisHandler = {
get: vi.fn(),
set: vi.fn(),
revalidateTag: vi.fn(),
resetRequestCache: vi.fn(),
};
(RedisStringsHandler as any).mockImplementation(() => mockRedisHandler);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("constructor", () => {
it("should create a single RedisStringsHandler instance", () => {
const options = { redisUrl: "redis://localhost:6379" };
handler = new CachedHandler(options);
expect(RedisStringsHandler).toHaveBeenCalledTimes(1);
expect(RedisStringsHandler).toHaveBeenCalledWith(options);
// Creating another instance should not create a new RedisStringsHandler
const _handler2 = new CachedHandler(options);
expect(RedisStringsHandler).toHaveBeenCalledTimes(1);
});
it("should log when cached handler is created", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
handler = new CachedHandler({});
expect(consoleSpy).toHaveBeenCalledWith("created cached handler");
consoleSpy.mockRestore();
});
});
describe("get", () => {
beforeEach(() => {
handler = new CachedHandler({});
});
it("should call RedisStringsHandler.get with correct arguments", async () => {
const key = "test-key";
const ctx = {
kind: "APP_ROUTE" as const,
isRoutePPREnabled: false,
isFallback: false,
};
const expectedResult = {
value: "test",
lastModified: Date.now(),
tags: [],
};
mockRedisHandler.get.mockResolvedValue(expectedResult);
const result = await handler.get(key, ctx);
expect(mockRedisHandler.get).toHaveBeenCalledWith(key, ctx);
expect(result).toBe(expectedResult);
});
it("should handle get with FETCH context", async () => {
const key = "fetch-key";
const ctx = {
kind: "FETCH" as const,
revalidate: 60,
fetchUrl: "https://example.com",
fetchIdx: 0,
tags: ["tag1"],
softTags: ["tag2"],
isFallback: false,
};
mockRedisHandler.get.mockResolvedValue(null);
const result = await handler.get(key, ctx);
expect(mockRedisHandler.get).toHaveBeenCalledWith(key, ctx);
expect(result).toBeNull();
});
it("should handle get with APP_PAGE context", async () => {
const key = "page-key";
const ctx = {
kind: "APP_PAGE" as const,
isRoutePPREnabled: true,
isFallback: false,
};
const expectedResult = {
value: "page-data",
lastModified: Date.now(),
tags: ["page-tag"],
};
mockRedisHandler.get.mockResolvedValue(expectedResult);
const result = await handler.get(key, ctx);
expect(mockRedisHandler.get).toHaveBeenCalledWith(key, ctx);
expect(result).toBe(expectedResult);
});
});
describe("set", () => {
beforeEach(() => {
handler = new CachedHandler({});
});
it("should call RedisStringsHandler.set with APP_ROUTE data", async () => {
const key = "route-key";
const data = {
kind: "APP_ROUTE" as const,
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"),
};
const ctx = {
isRoutePPREnabled: false,
isFallback: false,
revalidate: 60,
};
mockRedisHandler.set.mockResolvedValue(undefined);
await handler.set(key, data, ctx);
expect(mockRedisHandler.set).toHaveBeenCalledWith(key, data, ctx);
});
it("should call RedisStringsHandler.set with APP_PAGE data", async () => {
const key = "page-key";
const data = {
kind: "APP_PAGE" as const,
status: 200,
headers: {
"x-nextjs-stale-time": "1234567890",
"x-next-cache-tags": "page-tag",
},
html: "<html>content</html>",
rscData: Buffer.from("rsc data"),
segmentData: {},
postboned: null,
};
const ctx = {
isRoutePPREnabled: true,
isFallback: false,
tags: ["custom-tag"],
};
mockRedisHandler.set.mockResolvedValue(undefined);
await handler.set(key, data, ctx);
expect(mockRedisHandler.set).toHaveBeenCalledWith(key, data, ctx);
});
it("should call RedisStringsHandler.set with FETCH data", async () => {
const key = "fetch-key";
const data = {
kind: "FETCH" as const,
data: {
headers: { "content-type": "application/json" },
body: "base64data",
status: 200,
url: "https://example.com",
},
revalidate: 300,
};
const ctx = {
isRoutePPREnabled: false,
isFallback: false,
};
mockRedisHandler.set.mockResolvedValue(undefined);
await handler.set(key, data, ctx);
expect(mockRedisHandler.set).toHaveBeenCalledWith(key, data, ctx);
});
it("should propagate errors from RedisStringsHandler.set", async () => {
const key = "error-key";
const data = {
kind: "APP_ROUTE" as const,
status: 500,
headers: {
"x-nextjs-stale-time": "1234567890",
"x-next-cache-tags": "error-tag",
},
body: Buffer.from("error"),
};
const ctx = {
isRoutePPREnabled: false,
isFallback: false,
};
const error = new Error("Redis connection failed");
mockRedisHandler.set.mockRejectedValue(error);
await expect(handler.set(key, data, ctx)).rejects.toThrow(
"Redis connection failed"
);
});
});
describe("revalidateTag", () => {
beforeEach(() => {
handler = new CachedHandler({});
});
it("should call RedisStringsHandler.revalidateTag with single tag", async () => {
const tag = "test-tag";
mockRedisHandler.revalidateTag.mockResolvedValue(undefined);
await handler.revalidateTag(tag);
expect(mockRedisHandler.revalidateTag).toHaveBeenCalledWith(tag);
});
it("should call RedisStringsHandler.revalidateTag with multiple tags", async () => {
const tags = ["tag1", "tag2", "tag3"];
mockRedisHandler.revalidateTag.mockResolvedValue(undefined);
await handler.revalidateTag(tags);
expect(mockRedisHandler.revalidateTag).toHaveBeenCalledWith(tags);
});
it("should call RedisStringsHandler.revalidateTag with additional arguments", async () => {
const tag = "test-tag";
const additionalArg1 = "extra1";
const additionalArg2 = "extra2";
mockRedisHandler.revalidateTag.mockResolvedValue(undefined);
await handler.revalidateTag(tag, additionalArg1, additionalArg2);
expect(mockRedisHandler.revalidateTag).toHaveBeenCalledWith(
tag,
additionalArg1,
additionalArg2
);
});
it("should propagate errors from RedisStringsHandler.revalidateTag", async () => {
const tag = "error-tag";
const error = new Error("Revalidation failed");
mockRedisHandler.revalidateTag.mockRejectedValue(error);
await expect(handler.revalidateTag(tag)).rejects.toThrow(
"Revalidation failed"
);
});
});
describe("resetRequestCache", () => {
beforeEach(() => {
handler = new CachedHandler({});
});
it("should call RedisStringsHandler.resetRequestCache", () => {
mockRedisHandler.resetRequestCache.mockReturnValue(undefined);
const result = handler.resetRequestCache();
expect(mockRedisHandler.resetRequestCache).toHaveBeenCalledWith();
expect(result).toBeUndefined();
});
it("should call RedisStringsHandler.resetRequestCache with arguments", () => {
const arg1 = "test-arg";
const arg2 = { test: "object" };
mockRedisHandler.resetRequestCache.mockReturnValue(undefined);
const result = handler.resetRequestCache(arg1, arg2);
expect(mockRedisHandler.resetRequestCache).toHaveBeenCalledWith(
arg1,
arg2
);
expect(result).toBeUndefined();
});
});
describe("singleton behavior", () => {
it("should reuse the same RedisStringsHandler instance across multiple CachedHandler instances", () => {
const handler1 = new CachedHandler({
redisUrl: "redis://localhost:6379",
});
const handler2 = new CachedHandler({
redisUrl: "redis://localhost:6380",
}); // Different options
const handler3 = new CachedHandler({});
// Only one RedisStringsHandler should be created regardless of different options
expect(RedisStringsHandler).toHaveBeenCalledTimes(1);
// All handlers should use the same underlying Redis handler
const mockGet = vi.fn();
mockRedisHandler.get = mockGet;
handler1.get("key1", {
kind: "APP_ROUTE",
isRoutePPREnabled: false,
isFallback: false,
});
handler2.get("key2", {
kind: "APP_PAGE",
isRoutePPREnabled: true,
isFallback: false,
});
handler3.get("key3", {
kind: "FETCH",
revalidate: 60,
fetchUrl: "test",
fetchIdx: 0,
tags: [],
softTags: [],
isFallback: false,
});
expect(mockGet).toHaveBeenCalledTimes(3);
});
});
});