@safaricom-mxl/nextjs-turbo-redis-cache
Version:
Next.js redis cache handler
411 lines (342 loc) • 13.1 kB
text/typescript
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;
});
});
});