@safaricom-mxl/nextjs-turbo-redis-cache
Version:
Next.js redis cache handler
284 lines (219 loc) • 8.55 kB
text/typescript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DeduplicatedRequestHandler } from "../DeduplicatedRequestHandler";
describe("DeduplicatedRequestHandler", () => {
let handler: DeduplicatedRequestHandler<
(...args: [never, never]) => Promise<string>,
string
>;
let mockFn: vi.MockedFunction<(...args: [never, never]) => Promise<string>>;
let mockCache: any;
beforeEach(() => {
vi.clearAllMocks();
mockFn = vi.fn();
mockCache = {
has: vi.fn(),
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
};
handler = new DeduplicatedRequestHandler(mockFn, 1000, mockCache);
});
afterEach(() => {
vi.clearAllTimers();
vi.restoreAllMocks();
});
describe("seedRequestReturn", () => {
it("should seed a result into the cache", () => {
const key = "test-key";
const value = "test-value";
handler.seedRequestReturn(key, value);
expect(mockCache.set).toHaveBeenCalledWith(key, expect.any(Promise));
// Verify the promise resolves to the correct value
const promise = mockCache.set.mock.calls[0][1];
return expect(promise).resolves.toBe(value);
});
it("should automatically remove the seeded result after caching time", async () => {
vi.useFakeTimers();
const key = "test-key";
const value = "test-value";
handler.seedRequestReturn(key, value);
expect(mockCache.delete).not.toHaveBeenCalled();
// Fast forward time
vi.advanceTimersByTime(1000);
expect(mockCache.delete).toHaveBeenCalledWith(key);
vi.useRealTimers();
});
});
describe("deduplicatedFunction", () => {
let deduplicatedFn: (...args: [never, never]) => Promise<string>;
beforeEach(() => {
deduplicatedFn = handler.deduplicatedFunction("test-key");
});
it("should call the original function when no cached result exists", async () => {
mockCache.has.mockReturnValue(false);
mockFn.mockResolvedValue("original-result");
const result = await deduplicatedFn(
undefined as never,
undefined as never
);
expect(mockFn).toHaveBeenCalledWith(undefined, undefined);
expect(mockCache.set).toHaveBeenCalledWith(
"test-key",
expect.any(Promise)
);
expect(result).toBe("original-result");
});
it("should return cached result when available", async () => {
const cachedPromise = Promise.resolve("cached-result");
mockCache.has.mockReturnValue(true);
mockCache.get.mockReturnValue(cachedPromise);
const result = await deduplicatedFn(
undefined as never,
undefined as never
);
expect(mockFn).not.toHaveBeenCalled();
expect(result).toBe("cached-result");
});
it("should cache the promise from the original function", async () => {
mockCache.has.mockReturnValue(false);
mockFn.mockResolvedValue("function-result");
const promise = deduplicatedFn(undefined as never, undefined as never);
expect(mockCache.set).toHaveBeenCalledWith("test-key", promise);
expect(await promise).toBe("function-result");
});
it("should remove the cached result after caching timeout", async () => {
vi.useFakeTimers();
mockCache.has.mockReturnValue(false);
mockFn.mockResolvedValue("result");
await deduplicatedFn(undefined as never, undefined as never);
expect(mockCache.delete).not.toHaveBeenCalled();
// Fast forward time beyond caching timeout
vi.advanceTimersByTime(1000);
expect(mockCache.delete).toHaveBeenCalledWith("test-key");
vi.useRealTimers();
});
it("should handle function errors and still clean up cache", async () => {
vi.useFakeTimers();
mockCache.has.mockReturnValue(false);
mockFn.mockRejectedValue(new Error("Function error"));
await expect(
deduplicatedFn(undefined as never, undefined as never)
).rejects.toThrow("Function error");
// Should still clean up after timeout
vi.advanceTimersByTime(1000);
expect(mockCache.delete).toHaveBeenCalledWith("test-key");
vi.useRealTimers();
});
it("should return structurally cloned results to prevent mutation", async () => {
const originalObject = { value: "test", nested: { data: "nested" } };
// Test cached result cloning
mockCache.has.mockReturnValue(true);
mockCache.get.mockReturnValue(Promise.resolve(originalObject));
const cachedResult = await deduplicatedFn(
undefined as never,
undefined as never
);
// Modify the returned result
(cachedResult as any).value = "modified";
(cachedResult as any).nested.data = "modified-nested";
// Original should be unchanged (structuredClone protection)
expect(originalObject.value).toBe("test");
expect(originalObject.nested.data).toBe("nested");
// Test fresh function result cloning
mockCache.has.mockReturnValue(false);
mockFn.mockResolvedValue(originalObject);
const freshResult = await deduplicatedFn(
undefined as never,
undefined as never
);
// Modify the returned result
(freshResult as any).value = "modified-fresh";
// Original should be unchanged
expect(originalObject.value).toBe("test");
});
it("should handle multiple concurrent requests to the same key", async () => {
mockCache.has.mockReturnValueOnce(false).mockReturnValue(true);
const sharedPromise = Promise.resolve("shared-result");
mockCache.get.mockReturnValue(sharedPromise);
let resolveFunction: (value: string) => void;
const delayedPromise = new Promise<string>((resolve) => {
resolveFunction = resolve;
});
mockFn.mockReturnValue(delayedPromise);
// Start first request
const firstRequest = deduplicatedFn(
undefined as never,
undefined as never
);
// Start second request while first is pending
const secondRequest = deduplicatedFn(
undefined as never,
undefined as never
);
// The first request should set the cache
expect(mockCache.set).toHaveBeenCalledWith("test-key", delayedPromise);
// The second request should use the cached promise
expect(mockFn).toHaveBeenCalledTimes(1);
// Resolve the function
resolveFunction?.("shared-result");
// Both should resolve to the same result
expect(await firstRequest).toBe("shared-result");
expect(await secondRequest).toBe("shared-result");
});
});
describe("integration scenarios", () => {
it("should work with different cache implementations", () => {
const realCache = new Map<string, Promise<string>>();
const mockSyncedMap = {
has: (key: string) => realCache.has(key),
get: (key: string) => realCache.get(key),
set: (key: string, value: Promise<string>) => {
realCache.set(key, value);
},
delete: (key: string) => {
realCache.delete(key);
},
};
const realHandler = new DeduplicatedRequestHandler(
mockFn,
100,
mockSyncedMap as any
);
const deduplicatedFn = realHandler.deduplicatedFunction("real-key");
expect(deduplicatedFn).toBeInstanceOf(Function);
});
it("should handle different return types", async () => {
const objectFn = vi
.fn<(...args: [never, never]) => Promise<{ data: string }>>()
.mockResolvedValue({ data: "test-object" });
const objectHandler = new DeduplicatedRequestHandler(
objectFn,
1000,
mockCache
);
const deduplicatedObjectFn =
objectHandler.deduplicatedFunction("object-key");
mockCache.has.mockReturnValue(false);
const result = await deduplicatedObjectFn(
undefined as never,
undefined as never
);
expect(result).toEqual({ data: "test-object" });
const numberFn = vi
.fn<(...args: [never, never]) => Promise<number>>()
.mockResolvedValue(42);
const numberHandler = new DeduplicatedRequestHandler(
numberFn,
1000,
mockCache
);
const deduplicatedNumberFn =
numberHandler.deduplicatedFunction("number-key");
const numberResult = await deduplicatedNumberFn(
undefined as never,
undefined as never
);
expect(numberResult).toBe(42);
});
});
});