Files
hongjr03 4479f88f4f 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
2026-07-08 17:22:23 +08:00

108 lines
3.0 KiB
TypeScript

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);
});
});