import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { TTLStore } from "../ttl-store.js"; import { createMockLogger } from "./mock-logger.js"; describe("TTLStore", () => { let store: TTLStore<{ value: string; createdAt: number }>; beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { // Destroy store to clear intervals before restoring timers if (store) { store.destroy(); } vi.useRealTimers(); }); function createStore( ttlMs = 10 * 60 * 1000, cleanupIntervalMs = 5 * 60 * 1000 ) { store = new TTLStore<{ value: string; createdAt: number }>( (entry) => Date.now() - entry.createdAt > ttlMs, "test_store", cleanupIntervalMs ); return store; } describe("set and get", () => { it("stores and retrieves a value by key", () => { createStore(); const entry = { value: "hello", createdAt: Date.now() }; store.set("key1", entry); const result = store.get("key1"); expect(result).toEqual(entry); }); it("returns undefined for a missing key", () => { createStore(); const result = store.get("nonexistent"); expect(result).toBeUndefined(); }); it("overwrites existing values on set", () => { createStore(); store.set("key1", { value: "first", createdAt: Date.now() }); store.set("key1", { value: "second", createdAt: Date.now() }); const result = store.get("key1"); expect(result?.value).toBe("second"); }); }); describe("expiration on get", () => { it("returns undefined for an expired entry on get", () => { const ttlMs = 1000; createStore(ttlMs); store.set("key1", { value: "expires-soon", createdAt: Date.now() }); // Advance past TTL vi.advanceTimersByTime(ttlMs + 1); const result = store.get("key1"); expect(result).toBeUndefined(); }); it("returns value for a non-expired entry", () => { const ttlMs = 10_000; createStore(ttlMs); store.set("key1", { value: "still-valid", createdAt: Date.now() }); // Advance less than TTL vi.advanceTimersByTime(ttlMs - 1); const result = store.get("key1"); expect(result).toBeDefined(); expect(result?.value).toBe("still-valid"); }); it("eagerly deletes expired entry on access", () => { const ttlMs = 1000; createStore(ttlMs); store.set("key1", { value: "expired", createdAt: Date.now() }); vi.advanceTimersByTime(ttlMs + 1); // First access returns undefined and deletes expect(store.get("key1")).toBeUndefined(); // getUnchecked also returns undefined because it was deleted expect(store.getUnchecked("key1")).toBeUndefined(); }); }); describe("getUnchecked", () => { it("returns value without checking expiration", () => { const ttlMs = 1000; createStore(ttlMs); store.set("key1", { value: "raw-access", createdAt: Date.now() }); // Advance past TTL vi.advanceTimersByTime(ttlMs + 1); // getUnchecked does not check expiration const result = store.getUnchecked("key1"); expect(result).toBeDefined(); expect(result?.value).toBe("raw-access"); }); it("returns undefined for missing key", () => { createStore(); expect(store.getUnchecked("nonexistent")).toBeUndefined(); }); }); describe("delete", () => { it("removes an entry by key", () => { createStore(); store.set("key1", { value: "to-delete", createdAt: Date.now() }); store.delete("key1"); expect(store.get("key1")).toBeUndefined(); }); it("does not throw when deleting a missing key", () => { createStore(); expect(() => store.delete("nonexistent")).not.toThrow(); }); }); describe("background cleanup", () => { it("removes expired entries on cleanup interval", () => { const ttlMs = 1000; const cleanupIntervalMs = 5000; createStore(ttlMs, cleanupIntervalMs); store.set("key1", { value: "will-expire", createdAt: Date.now() }); // Advance past TTL but not past cleanup interval vi.advanceTimersByTime(ttlMs + 1); // Entry still in raw storage (not yet cleaned up by interval) expect(store.getUnchecked("key1")).toBeDefined(); // Advance to trigger cleanup interval vi.advanceTimersByTime(cleanupIntervalMs); // Entry should be removed by cleanup expect(store.getUnchecked("key1")).toBeUndefined(); }); it("logs when expired entries are cleaned up", () => { const ttlMs = 1000; const cleanupIntervalMs = 5000; const mockLogger = createMockLogger(); store = new TTLStore<{ value: string; createdAt: number }>( (entry) => Date.now() - entry.createdAt > ttlMs, "test_store", cleanupIntervalMs, mockLogger, ); store.set("key1", { value: "expired-1", createdAt: Date.now() }); store.set("key2", { value: "expired-2", createdAt: Date.now() }); // Advance past TTL + cleanup interval vi.advanceTimersByTime(ttlMs + cleanupIntervalMs + 1); expect(mockLogger.info).toHaveBeenCalledWith( "test_store cleanup completed", expect.objectContaining({ operation: "test_store.cleanup", cleanedCount: 2, remainingCount: 0, }) ); }); it("does not log when no entries are expired", () => { const ttlMs = 60_000; const cleanupIntervalMs = 5000; const mockLogger = createMockLogger(); store = new TTLStore<{ value: string; createdAt: number }>( (entry) => Date.now() - entry.createdAt > ttlMs, "test_store", cleanupIntervalMs, mockLogger, ); store.set("key1", { value: "still-fresh", createdAt: Date.now() }); // Advance to trigger cleanup, but entries are not expired vi.advanceTimersByTime(cleanupIntervalMs + 1); expect(mockLogger.info).not.toHaveBeenCalled(); }); it("keeps non-expired entries during cleanup", () => { const ttlMs = 10_000; const cleanupIntervalMs = 5000; createStore(ttlMs, cleanupIntervalMs); const now = Date.now(); store.set("old", { value: "old-entry", createdAt: now - ttlMs - 1 }); store.set("fresh", { value: "fresh-entry", createdAt: now }); // Trigger cleanup vi.advanceTimersByTime(cleanupIntervalMs + 1); // Old entry removed, fresh entry kept expect(store.getUnchecked("old")).toBeUndefined(); expect(store.getUnchecked("fresh")).toBeDefined(); }); it("handles cleanup errors gracefully", () => { const cleanupIntervalMs = 5000; const mockLogger = createMockLogger(); // Create a store with an isExpired that throws store = new TTLStore<{ value: string; createdAt: number }>( () => { throw new Error("expiration check failed"); }, "error_store", cleanupIntervalMs, mockLogger, ); store.set("key1", { value: "test", createdAt: Date.now() }); // Trigger cleanup - should not throw vi.advanceTimersByTime(cleanupIntervalMs + 1); expect(mockLogger.error).toHaveBeenCalledWith( "error_store cleanup failed", expect.objectContaining({ operation: "error_store.cleanup", error: "expiration check failed", }) ); }); }); describe("destroy", () => { it("stops the cleanup interval", () => { const ttlMs = 1000; const cleanupIntervalMs = 5000; const mockLogger = createMockLogger(); store = new TTLStore<{ value: string; createdAt: number }>( (entry) => Date.now() - entry.createdAt > ttlMs, "test_store", cleanupIntervalMs, mockLogger, ); store.set("key1", { value: "expired", createdAt: Date.now() }); // Advance past TTL vi.advanceTimersByTime(ttlMs + 1); // Destroy before cleanup runs store.destroy(); // Advance past cleanup interval vi.advanceTimersByTime(cleanupIntervalMs + 1); // Cleanup should NOT have run (no log message) expect(mockLogger.info).not.toHaveBeenCalled(); }); }); describe("custom cleanup interval", () => { it("uses the provided cleanup interval", () => { const ttlMs = 500; const cleanupIntervalMs = 1000; createStore(ttlMs, cleanupIntervalMs); store.set("key1", { value: "test", createdAt: Date.now() }); // Advance past TTL vi.advanceTimersByTime(ttlMs + 1); // Not yet at cleanup interval expect(store.getUnchecked("key1")).toBeDefined(); // Advance to cleanup vi.advanceTimersByTime(cleanupIntervalMs); // Now it should be cleaned expect(store.getUnchecked("key1")).toBeUndefined(); }); }); describe("multiple entries", () => { it("handles multiple independent keys", () => { createStore(); const now = Date.now(); store.set("a", { value: "alpha", createdAt: now }); store.set("b", { value: "beta", createdAt: now }); store.set("c", { value: "gamma", createdAt: now }); expect(store.get("a")?.value).toBe("alpha"); expect(store.get("b")?.value).toBe("beta"); expect(store.get("c")?.value).toBe("gamma"); store.delete("b"); expect(store.get("a")?.value).toBe("alpha"); expect(store.get("b")).toBeUndefined(); expect(store.get("c")?.value).toBe("gamma"); }); }); });