forked from EduCraft/curriculum-project-hub
feat: add sender profile cache with LRU eviction
- SenderNameCache: TTL 10min, LRU max 2048 entries, getOrFetch pattern - resolveSenderName: API call with cache integration - Trigger uses cache for approval card resolutions and audit metadata - Add unit tests (9) for cache lifecycle, LRU, expiry, and null handling
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SenderNameCache } from "../../src/feishu/senderCache.js";
|
||||
|
||||
describe("SenderNameCache", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("get returns null for missing key", () => {
|
||||
const cache = new SenderNameCache();
|
||||
|
||||
expect(cache.get("ou_user")).toBeNull();
|
||||
});
|
||||
|
||||
it("set then get returns the name", () => {
|
||||
const cache = new SenderNameCache();
|
||||
|
||||
cache.set("ou_user", "Alice");
|
||||
|
||||
expect(cache.get("ou_user")).toBe("Alice");
|
||||
});
|
||||
|
||||
it("expired entry returns null", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const cache = new SenderNameCache({ ttlMs: 100 });
|
||||
|
||||
cache.set("ou_user", "Alice");
|
||||
vi.advanceTimersByTime(101);
|
||||
|
||||
expect(cache.get("ou_user")).toBeNull();
|
||||
expect(cache.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("getOrFetch uses cache on hit", async () => {
|
||||
const cache = new SenderNameCache();
|
||||
const fetchFn = vi.fn(async () => "Fetched Alice");
|
||||
cache.set("ou_user", "Alice");
|
||||
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getOrFetch calls fetchFn on miss", async () => {
|
||||
const cache = new SenderNameCache();
|
||||
const fetchFn = vi.fn(async () => "Alice");
|
||||
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(fetchFn).toHaveBeenCalledWith("ou_user");
|
||||
});
|
||||
|
||||
it("getOrFetch caches the result of fetchFn", async () => {
|
||||
const cache = new SenderNameCache();
|
||||
const fetchFn = vi.fn(async () => "Alice");
|
||||
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBe("Alice");
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("evicts the least recently used entry when full", () => {
|
||||
const cache = new SenderNameCache({ maxSize: 2 });
|
||||
|
||||
cache.set("ou_1", "Alice");
|
||||
cache.set("ou_2", "Bob");
|
||||
expect(cache.get("ou_1")).toBe("Alice");
|
||||
cache.set("ou_3", "Carol");
|
||||
|
||||
expect(cache.get("ou_1")).toBe("Alice");
|
||||
expect(cache.get("ou_2")).toBeNull();
|
||||
expect(cache.get("ou_3")).toBe("Carol");
|
||||
});
|
||||
|
||||
it("clear empties the cache", () => {
|
||||
const cache = new SenderNameCache();
|
||||
|
||||
cache.set("ou_user", "Alice");
|
||||
cache.clear();
|
||||
|
||||
expect(cache.get("ou_user")).toBeNull();
|
||||
expect(cache.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("size returns correct count", () => {
|
||||
const cache = new SenderNameCache();
|
||||
|
||||
cache.set("ou_1", "Alice");
|
||||
cache.set("ou_2", "Bob");
|
||||
|
||||
expect(cache.size()).toBe(2);
|
||||
});
|
||||
|
||||
it("getOrFetch with null fetchFn result does not cache null", async () => {
|
||||
const cache = new SenderNameCache();
|
||||
const fetchFn = vi.fn(async () => null);
|
||||
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
|
||||
await expect(cache.getOrFetch("ou_user", fetchFn)).resolves.toBeNull();
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
expect(cache.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user