@safaricom-mxl/nextjs-turbo-redis-cache
Version:
Next.js redis cache handler
367 lines (299 loc) • 10.7 kB
text/typescript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import RedisStringsHandler from "../RedisStringsHandler";
import CachedHandler, { resetCachedHandler } from "../ZodHandler";
// Mock the RedisStringsHandler
vi.mock("../RedisStringsHandler", () => ({
default: vi.fn().mockImplementation(() => ({
get: vi.fn(),
set: vi.fn(),
revalidateTag: vi.fn(),
resetRequestCache: vi.fn(),
})),
}));
describe("ZodHandler (Development 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 for development", () => {
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 (singleton pattern)
const _handler2 = new CachedHandler(options);
expect(RedisStringsHandler).toHaveBeenCalledTimes(1);
});
it("should log when development cached handler is created", () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
handler = new CachedHandler({});
expect(consoleSpy).toHaveBeenCalledWith(
"created development cached handler"
);
consoleSpy.mockRestore();
});
});
describe("get method", () => {
beforeEach(() => {
handler = new CachedHandler({});
});
it("should delegate get calls to RedisStringsHandler 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 different context types for validation purposes", async () => {
const contexts = [
{
kind: "APP_ROUTE" as const,
isRoutePPREnabled: false,
isFallback: false,
},
{
kind: "APP_PAGE" as const,
isRoutePPREnabled: true,
isFallback: false,
},
{
kind: "FETCH" as const,
revalidate: 60,
fetchUrl: "https://example.com",
fetchIdx: 0,
tags: ["tag1"],
softTags: ["tag2"],
isFallback: false,
},
];
for (const ctx of contexts) {
mockRedisHandler.get.mockResolvedValue(null);
const result = await handler.get("test-key", ctx);
expect(mockRedisHandler.get).toHaveBeenCalledWith("test-key", ctx);
expect(result).toBeNull();
}
expect(mockRedisHandler.get).toHaveBeenCalledTimes(3);
});
});
describe("set method", () => {
beforeEach(() => {
handler = new CachedHandler({});
});
it("should delegate set calls to RedisStringsHandler 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 validate and delegate APP_PAGE data types", 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: { test: "data" },
postboned: { delayed: "content" },
};
const ctx = {
isRoutePPREnabled: true,
isFallback: false,
tags: ["custom-tag"],
cacheControl: { revalidate: 5, expire: undefined },
};
mockRedisHandler.set.mockResolvedValue(undefined);
await handler.set(key, data, ctx);
expect(mockRedisHandler.set).toHaveBeenCalledWith(key, data, ctx);
});
it("should validate and delegate FETCH data types", async () => {
const key = "fetch-key";
const data = {
kind: "FETCH" as const,
data: {
headers: { "content-type": "application/json" },
body: "YmFzZTY0ZGF0YQ==", // base64 encoded data
status: 200,
url: "https://api.example.com/data",
},
revalidate: 300,
};
const ctx = {
isRoutePPREnabled: false,
isFallback: false,
revalidate: false,
};
mockRedisHandler.set.mockResolvedValue(undefined);
await handler.set(key, data, ctx);
expect(mockRedisHandler.set).toHaveBeenCalledWith(key, data, ctx);
});
});
describe("revalidateTag method", () => {
beforeEach(() => {
handler = new CachedHandler({});
});
it("should delegate revalidateTag calls with single tag", async () => {
const tag = "test-tag";
mockRedisHandler.revalidateTag.mockResolvedValue(undefined);
await handler.revalidateTag(tag);
expect(mockRedisHandler.revalidateTag).toHaveBeenCalledWith(tag);
});
it("should delegate revalidateTag calls with multiple tags", async () => {
const tags = ["tag1", "tag2", "tag3"];
mockRedisHandler.revalidateTag.mockResolvedValue(undefined);
await handler.revalidateTag(tags);
expect(mockRedisHandler.revalidateTag).toHaveBeenCalledWith(tags);
});
it("should handle revalidateTag with additional arguments for validation", async () => {
const tag = "validation-tag";
const additionalArg1 = "extra-validation-data";
const additionalArg2 = { validation: "object" };
mockRedisHandler.revalidateTag.mockResolvedValue(undefined);
await handler.revalidateTag(tag, additionalArg1, additionalArg2);
expect(mockRedisHandler.revalidateTag).toHaveBeenCalledWith(
tag,
additionalArg1,
additionalArg2
);
});
});
describe("resetRequestCache method", () => {
beforeEach(() => {
handler = new CachedHandler({});
});
it("should delegate resetRequestCache calls", () => {
mockRedisHandler.resetRequestCache.mockReturnValue(undefined);
const result = handler.resetRequestCache();
expect(mockRedisHandler.resetRequestCache).toHaveBeenCalledWith();
expect(result).toBeUndefined();
});
it("should handle resetRequestCache with arguments", () => {
const arg1 = "cache-reset-arg";
const arg2 = { reset: "options" };
mockRedisHandler.resetRequestCache.mockReturnValue(undefined);
const result = handler.resetRequestCache(arg1, arg2);
expect(mockRedisHandler.resetRequestCache).toHaveBeenCalledWith(
arg1,
arg2
);
expect(result).toBeUndefined();
});
});
describe("development validation behavior", () => {
it("should maintain the same singleton pattern as production handler", () => {
const handler1 = new CachedHandler({ redisUrl: "redis://dev:6379" });
const handler2 = new CachedHandler({ redisUrl: "redis://staging:6379" });
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 for development consistency
const mockGet = vi.fn();
mockRedisHandler.get = mockGet;
handler1.get("dev-key", {
kind: "APP_ROUTE",
isRoutePPREnabled: false,
isFallback: false,
});
handler2.get("staging-key", {
kind: "APP_PAGE",
isRoutePPREnabled: true,
isFallback: false,
});
handler3.get("local-key", {
kind: "FETCH",
revalidate: 60,
fetchUrl: "test",
fetchIdx: 0,
tags: [],
softTags: [],
isFallback: false,
});
expect(mockGet).toHaveBeenCalledTimes(3);
});
it("should provide the same interface as the production CachedHandler", () => {
handler = new CachedHandler({});
// Verify all expected methods are available
expect(typeof handler.get).toBe("function");
expect(typeof handler.set).toBe("function");
expect(typeof handler.revalidateTag).toBe("function");
expect(typeof handler.resetRequestCache).toBe("function");
});
it("should handle error scenarios for validation testing", async () => {
handler = new CachedHandler({});
// Test error propagation
const error = new Error("Validation error");
mockRedisHandler.get.mockRejectedValue(error);
await expect(
handler.get("error-key", {
kind: "APP_ROUTE",
isRoutePPREnabled: false,
isFallback: false,
})
).rejects.toThrow("Validation error");
mockRedisHandler.set.mockRejectedValue(error);
await expect(
handler.set(
"error-key",
{
kind: "APP_ROUTE",
status: 500,
headers: {
"x-nextjs-stale-time": "1234567890",
"x-next-cache-tags": "error-tag",
},
body: Buffer.from("error"),
},
{
isRoutePPREnabled: false,
isFallback: false,
}
)
).rejects.toThrow("Validation error");
mockRedisHandler.revalidateTag.mockRejectedValue(error);
await expect(handler.revalidateTag("error-tag")).rejects.toThrow(
"Validation error"
);
});
});
});