UNPKG

@safaricom-mxl/nextjs-turbo-redis-cache

Version:

Next.js redis cache handler

388 lines (336 loc) 10.8 kB
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(); }); }); });