UNPKG

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

Version:

Next.js redis cache handler

411 lines (342 loc) 13.1 kB
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Client } from "../RedisStringsHandler"; import { SyncedMap } from "../SyncedMap"; describe("SyncedMap", () => { let mockClient: any; let mockSubscriberClient: any; let syncedMap: SyncedMap<string>; beforeEach(() => { vi.clearAllMocks(); mockSubscriberClient = { connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), subscribe: vi.fn().mockResolvedValue(undefined), on: vi.fn(), configGet: vi.fn().mockResolvedValue({ "notify-keyspace-events": "Exe" }), }; mockClient = { connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), duplicate: vi.fn().mockReturnValue(mockSubscriberClient), hScan: vi.fn().mockResolvedValue({ cursor: 0, tuples: [] }), scan: vi.fn().mockResolvedValue({ cursor: 0, keys: [] }), hSet: vi.fn().mockResolvedValue(1), hDel: vi.fn().mockResolvedValue(1), hGet: vi.fn(), publish: vi.fn().mockResolvedValue(1), isReady: true, }; }); afterEach(async () => { // Clean up any SyncedMap instances to prevent background operations if (syncedMap && typeof syncedMap.close === "function") { try { await syncedMap.close(); } catch (_e) { // Ignore cleanup errors } } vi.restoreAllMocks(); }); describe("constructor and setup", () => { it("should create a SyncedMap with default configuration", async () => { syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), }); // Wait for setup to complete await syncedMap.waitUntilReady(); expect(mockClient.duplicate).toHaveBeenCalled(); expect(mockSubscriberClient.connect).toHaveBeenCalled(); expect(mockClient.hScan).toHaveBeenCalled(); }); it("should skip Redis hashmap when customizedSync.withoutRedisHashmap is true", async () => { syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), customizedSync: { withoutRedisHashmap: true }, }); await syncedMap.waitUntilReady(); expect(mockClient.hScan).not.toHaveBeenCalled(); }); it("should setup periodic resync when resyncIntervalMs is provided", async () => { const setIntervalSpy = vi.spyOn(global, "setInterval"); syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), resyncIntervalMs: 5000, }); await syncedMap.waitUntilReady(); expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 5000); setIntervalSpy.mockRestore(); }); }); describe("data operations", () => { beforeEach(async () => { syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), }); await syncedMap.waitUntilReady(); }); describe("get", () => { it("should return undefined for non-existent key", () => { const result = syncedMap.get("non-existent"); expect(result).toBeUndefined(); }); it("should return value for existing key", async () => { await syncedMap.set("test-key", "test-value"); const result = syncedMap.get("test-key"); expect(result).toBe("test-value"); }); }); describe("set", () => { it("should set value in local map and Redis", async () => { await syncedMap.set("test-key", "test-value"); expect(syncedMap.get("test-key")).toBe("test-value"); expect(mockClient.hSet).toHaveBeenCalledWith( "test:testMap", "test-key", JSON.stringify("test-value") ); expect(mockClient.publish).toHaveBeenCalledWith( "test::sync-channel:testMap", JSON.stringify({ type: "insert", key: "test-key", value: "test-value", }) ); }); it("should skip Redis operations when withoutSetSync is true", async () => { syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), customizedSync: { withoutSetSync: true }, }); await syncedMap.waitUntilReady(); vi.clearAllMocks(); await syncedMap.set("test-key", "test-value"); expect(syncedMap.get("test-key")).toBe("test-value"); expect(mockClient.hSet).not.toHaveBeenCalled(); expect(mockClient.publish).not.toHaveBeenCalled(); }); it("should skip Redis hashmap when withoutRedisHashmap is true", async () => { syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), customizedSync: { withoutRedisHashmap: true }, }); await syncedMap.waitUntilReady(); vi.clearAllMocks(); await syncedMap.set("test-key", "test-value"); expect(mockClient.hSet).not.toHaveBeenCalled(); expect(mockClient.publish).toHaveBeenCalled(); }); }); describe("delete", () => { it("should delete single key from local map and Redis", async () => { await syncedMap.set("test-key", "test-value"); vi.clearAllMocks(); await syncedMap.delete("test-key"); expect(syncedMap.get("test-key")).toBeUndefined(); expect(mockClient.hDel).toHaveBeenCalledWith("test:testMap", [ "test-key", ]); expect(mockClient.publish).toHaveBeenCalledWith( "test::sync-channel:testMap", JSON.stringify({ type: "delete", keys: ["test-key"], }) ); }); it("should delete multiple keys from local map and Redis", async () => { await syncedMap.set("key1", "value1"); await syncedMap.set("key2", "value2"); vi.clearAllMocks(); await syncedMap.delete(["key1", "key2"]); expect(syncedMap.get("key1")).toBeUndefined(); expect(syncedMap.get("key2")).toBeUndefined(); expect(mockClient.hDel).toHaveBeenCalledWith("test:testMap", [ "key1", "key2", ]); }); it("should skip sync message when withoutSyncMessage is true", async () => { await syncedMap.set("test-key", "test-value"); vi.clearAllMocks(); await syncedMap.delete("test-key", true); expect(syncedMap.get("test-key")).toBeUndefined(); expect(mockClient.hDel).toHaveBeenCalled(); expect(mockClient.publish).not.toHaveBeenCalled(); }); }); describe("has", () => { it("should return false for non-existent key", () => { expect(syncedMap.has("non-existent")).toBe(false); }); it("should return true for existing key", async () => { await syncedMap.set("test-key", "test-value"); expect(syncedMap.has("test-key")).toBe(true); }); }); describe("entries", () => { it("should return map entries", async () => { await syncedMap.set("key1", "value1"); await syncedMap.set("key2", "value2"); const entries = Array.from(syncedMap.entries()); expect(entries).toEqual([ ["key1", "value1"], ["key2", "value2"], ]); }); }); }); describe("sync functionality", () => { beforeEach(async () => { syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), }); await syncedMap.waitUntilReady(); }); it("should handle initial sync with data from Redis", async () => { // Clear existing mock calls and setup fresh mocks mockClient.hScan.mockReset(); // Return all data in first call to avoid pagination complexity mockClient.hScan.mockImplementation(() => Promise.resolve({ cursor: 0, // End pagination immediately tuples: [ { field: "key1", value: JSON.stringify("value1") }, { field: "key2", value: JSON.stringify("value2") }, { field: "key3", value: JSON.stringify("value3") }, ], }) ); // Create new instance to trigger initial sync const newSyncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "newTestMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), }); await newSyncedMap.waitUntilReady(); // Verify hScan was called and data populated expect(mockClient.hScan).toHaveBeenCalled(); expect(newSyncedMap.get("key1")).toBe("value1"); expect(newSyncedMap.get("key2")).toBe("value2"); expect(newSyncedMap.get("key3")).toBe("value3"); }); it("should handle keyspace notification events", async () => { // Mock keyspace notification handling const keyEventHandler = mockSubscriberClient.subscribe.mock.calls.find( (call) => call[0].includes("__keyevent") )?.[1]; expect(keyEventHandler).toBeInstanceOf(Function); if (keyEventHandler) { await syncedMap.set("test-key", "test-value"); vi.clearAllMocks(); // Simulate expired keyspace event await keyEventHandler("test:test-key", "expired"); expect(syncedMap.get("test-key")).toBeUndefined(); } }); it("should handle Redis connection errors and reconnect", async () => { const errorHandler = mockSubscriberClient.on.mock.calls.find( (call) => call[0] === "error" )?.[1]; expect(errorHandler).toBeInstanceOf(Function); if (errorHandler) { mockSubscriberClient.disconnect.mockResolvedValueOnce(undefined); mockClient.duplicate.mockReturnValueOnce(mockSubscriberClient); await errorHandler(new Error("Connection lost")); expect(mockSubscriberClient.disconnect).toHaveBeenCalled(); expect(mockClient.duplicate).toHaveBeenCalled(); } }); }); describe("error handling", () => { it("should handle Redis operation errors gracefully", async () => { mockClient.hSet.mockRejectedValue(new Error("Redis error")); syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), }); await syncedMap.waitUntilReady(); // Should still set in local map even if Redis fails await expect(syncedMap.set("test-key", "test-value")).rejects.toThrow( "Redis error" ); expect(syncedMap.get("test-key")).toBe("test-value"); }); it("should throw error when keyspace events are not configured", async () => { mockSubscriberClient.configGet.mockResolvedValue({ "notify-keyspace-events": "", }); const syncedMapPromise = (async () => { syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), }); await syncedMap.waitUntilReady(); })(); await expect(syncedMapPromise).rejects.toThrow( "Keyspace event configuration" ); }); it("should skip keyspace config check when SKIP_KEYSPACE_CONFIG_CHECK is true", async () => { process.env.SKIP_KEYSPACE_CONFIG_CHECK = "TRUE"; syncedMap = new SyncedMap<string>({ client: mockClient as Client, keyPrefix: "test:", redisKey: "testMap", database: 0, querySize: 100, filterKeys: (key: string) => !key.startsWith("__"), }); await syncedMap.waitUntilReady(); expect(mockSubscriberClient.configGet).not.toHaveBeenCalled(); process.env.SKIP_KEYSPACE_CONFIG_CHECK = undefined; }); }); });