WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at root/atb-56-theme-caching-layer 319 lines 9.6 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2import { TTLStore } from "../ttl-store.js"; 3import { createMockLogger } from "./mock-logger.js"; 4 5describe("TTLStore", () => { 6 let store: TTLStore<{ value: string; createdAt: number }>; 7 8 beforeEach(() => { 9 vi.useFakeTimers(); 10 }); 11 12 afterEach(() => { 13 // Destroy store to clear intervals before restoring timers 14 if (store) { 15 store.destroy(); 16 } 17 vi.useRealTimers(); 18 }); 19 20 function createStore( 21 ttlMs = 10 * 60 * 1000, 22 cleanupIntervalMs = 5 * 60 * 1000 23 ) { 24 store = new TTLStore<{ value: string; createdAt: number }>( 25 (entry) => Date.now() - entry.createdAt > ttlMs, 26 "test_store", 27 cleanupIntervalMs 28 ); 29 return store; 30 } 31 32 describe("set and get", () => { 33 it("stores and retrieves a value by key", () => { 34 createStore(); 35 const entry = { value: "hello", createdAt: Date.now() }; 36 store.set("key1", entry); 37 38 const result = store.get("key1"); 39 expect(result).toEqual(entry); 40 }); 41 42 it("returns undefined for a missing key", () => { 43 createStore(); 44 const result = store.get("nonexistent"); 45 expect(result).toBeUndefined(); 46 }); 47 48 it("overwrites existing values on set", () => { 49 createStore(); 50 store.set("key1", { value: "first", createdAt: Date.now() }); 51 store.set("key1", { value: "second", createdAt: Date.now() }); 52 53 const result = store.get("key1"); 54 expect(result?.value).toBe("second"); 55 }); 56 }); 57 58 describe("expiration on get", () => { 59 it("returns undefined for an expired entry on get", () => { 60 const ttlMs = 1000; 61 createStore(ttlMs); 62 store.set("key1", { value: "expires-soon", createdAt: Date.now() }); 63 64 // Advance past TTL 65 vi.advanceTimersByTime(ttlMs + 1); 66 67 const result = store.get("key1"); 68 expect(result).toBeUndefined(); 69 }); 70 71 it("returns value for a non-expired entry", () => { 72 const ttlMs = 10_000; 73 createStore(ttlMs); 74 store.set("key1", { value: "still-valid", createdAt: Date.now() }); 75 76 // Advance less than TTL 77 vi.advanceTimersByTime(ttlMs - 1); 78 79 const result = store.get("key1"); 80 expect(result).toBeDefined(); 81 expect(result?.value).toBe("still-valid"); 82 }); 83 84 it("eagerly deletes expired entry on access", () => { 85 const ttlMs = 1000; 86 createStore(ttlMs); 87 store.set("key1", { value: "expired", createdAt: Date.now() }); 88 89 vi.advanceTimersByTime(ttlMs + 1); 90 91 // First access returns undefined and deletes 92 expect(store.get("key1")).toBeUndefined(); 93 94 // getUnchecked also returns undefined because it was deleted 95 expect(store.getUnchecked("key1")).toBeUndefined(); 96 }); 97 }); 98 99 describe("getUnchecked", () => { 100 it("returns value without checking expiration", () => { 101 const ttlMs = 1000; 102 createStore(ttlMs); 103 store.set("key1", { value: "raw-access", createdAt: Date.now() }); 104 105 // Advance past TTL 106 vi.advanceTimersByTime(ttlMs + 1); 107 108 // getUnchecked does not check expiration 109 const result = store.getUnchecked("key1"); 110 expect(result).toBeDefined(); 111 expect(result?.value).toBe("raw-access"); 112 }); 113 114 it("returns undefined for missing key", () => { 115 createStore(); 116 expect(store.getUnchecked("nonexistent")).toBeUndefined(); 117 }); 118 }); 119 120 describe("delete", () => { 121 it("removes an entry by key", () => { 122 createStore(); 123 store.set("key1", { value: "to-delete", createdAt: Date.now() }); 124 125 store.delete("key1"); 126 127 expect(store.get("key1")).toBeUndefined(); 128 }); 129 130 it("does not throw when deleting a missing key", () => { 131 createStore(); 132 expect(() => store.delete("nonexistent")).not.toThrow(); 133 }); 134 }); 135 136 describe("background cleanup", () => { 137 it("removes expired entries on cleanup interval", () => { 138 const ttlMs = 1000; 139 const cleanupIntervalMs = 5000; 140 createStore(ttlMs, cleanupIntervalMs); 141 142 store.set("key1", { value: "will-expire", createdAt: Date.now() }); 143 144 // Advance past TTL but not past cleanup interval 145 vi.advanceTimersByTime(ttlMs + 1); 146 147 // Entry still in raw storage (not yet cleaned up by interval) 148 expect(store.getUnchecked("key1")).toBeDefined(); 149 150 // Advance to trigger cleanup interval 151 vi.advanceTimersByTime(cleanupIntervalMs); 152 153 // Entry should be removed by cleanup 154 expect(store.getUnchecked("key1")).toBeUndefined(); 155 }); 156 157 it("logs when expired entries are cleaned up", () => { 158 const ttlMs = 1000; 159 const cleanupIntervalMs = 5000; 160 const mockLogger = createMockLogger(); 161 162 store = new TTLStore<{ value: string; createdAt: number }>( 163 (entry) => Date.now() - entry.createdAt > ttlMs, 164 "test_store", 165 cleanupIntervalMs, 166 mockLogger, 167 ); 168 store.set("key1", { value: "expired-1", createdAt: Date.now() }); 169 store.set("key2", { value: "expired-2", createdAt: Date.now() }); 170 171 // Advance past TTL + cleanup interval 172 vi.advanceTimersByTime(ttlMs + cleanupIntervalMs + 1); 173 174 expect(mockLogger.info).toHaveBeenCalledWith( 175 "test_store cleanup completed", 176 expect.objectContaining({ 177 operation: "test_store.cleanup", 178 cleanedCount: 2, 179 remainingCount: 0, 180 }) 181 ); 182 }); 183 184 it("does not log when no entries are expired", () => { 185 const ttlMs = 60_000; 186 const cleanupIntervalMs = 5000; 187 const mockLogger = createMockLogger(); 188 189 store = new TTLStore<{ value: string; createdAt: number }>( 190 (entry) => Date.now() - entry.createdAt > ttlMs, 191 "test_store", 192 cleanupIntervalMs, 193 mockLogger, 194 ); 195 store.set("key1", { value: "still-fresh", createdAt: Date.now() }); 196 197 // Advance to trigger cleanup, but entries are not expired 198 vi.advanceTimersByTime(cleanupIntervalMs + 1); 199 200 expect(mockLogger.info).not.toHaveBeenCalled(); 201 }); 202 203 it("keeps non-expired entries during cleanup", () => { 204 const ttlMs = 10_000; 205 const cleanupIntervalMs = 5000; 206 createStore(ttlMs, cleanupIntervalMs); 207 208 const now = Date.now(); 209 store.set("old", { value: "old-entry", createdAt: now - ttlMs - 1 }); 210 store.set("fresh", { value: "fresh-entry", createdAt: now }); 211 212 // Trigger cleanup 213 vi.advanceTimersByTime(cleanupIntervalMs + 1); 214 215 // Old entry removed, fresh entry kept 216 expect(store.getUnchecked("old")).toBeUndefined(); 217 expect(store.getUnchecked("fresh")).toBeDefined(); 218 }); 219 220 it("handles cleanup errors gracefully", () => { 221 const cleanupIntervalMs = 5000; 222 const mockLogger = createMockLogger(); 223 224 // Create a store with an isExpired that throws 225 store = new TTLStore<{ value: string; createdAt: number }>( 226 () => { 227 throw new Error("expiration check failed"); 228 }, 229 "error_store", 230 cleanupIntervalMs, 231 mockLogger, 232 ); 233 234 store.set("key1", { value: "test", createdAt: Date.now() }); 235 236 // Trigger cleanup - should not throw 237 vi.advanceTimersByTime(cleanupIntervalMs + 1); 238 239 expect(mockLogger.error).toHaveBeenCalledWith( 240 "error_store cleanup failed", 241 expect.objectContaining({ 242 operation: "error_store.cleanup", 243 error: "expiration check failed", 244 }) 245 ); 246 }); 247 }); 248 249 describe("destroy", () => { 250 it("stops the cleanup interval", () => { 251 const ttlMs = 1000; 252 const cleanupIntervalMs = 5000; 253 const mockLogger = createMockLogger(); 254 255 store = new TTLStore<{ value: string; createdAt: number }>( 256 (entry) => Date.now() - entry.createdAt > ttlMs, 257 "test_store", 258 cleanupIntervalMs, 259 mockLogger, 260 ); 261 store.set("key1", { value: "expired", createdAt: Date.now() }); 262 263 // Advance past TTL 264 vi.advanceTimersByTime(ttlMs + 1); 265 266 // Destroy before cleanup runs 267 store.destroy(); 268 269 // Advance past cleanup interval 270 vi.advanceTimersByTime(cleanupIntervalMs + 1); 271 272 // Cleanup should NOT have run (no log message) 273 expect(mockLogger.info).not.toHaveBeenCalled(); 274 }); 275 }); 276 277 describe("custom cleanup interval", () => { 278 it("uses the provided cleanup interval", () => { 279 const ttlMs = 500; 280 const cleanupIntervalMs = 1000; 281 createStore(ttlMs, cleanupIntervalMs); 282 283 store.set("key1", { value: "test", createdAt: Date.now() }); 284 285 // Advance past TTL 286 vi.advanceTimersByTime(ttlMs + 1); 287 288 // Not yet at cleanup interval 289 expect(store.getUnchecked("key1")).toBeDefined(); 290 291 // Advance to cleanup 292 vi.advanceTimersByTime(cleanupIntervalMs); 293 294 // Now it should be cleaned 295 expect(store.getUnchecked("key1")).toBeUndefined(); 296 }); 297 }); 298 299 describe("multiple entries", () => { 300 it("handles multiple independent keys", () => { 301 createStore(); 302 const now = Date.now(); 303 304 store.set("a", { value: "alpha", createdAt: now }); 305 store.set("b", { value: "beta", createdAt: now }); 306 store.set("c", { value: "gamma", createdAt: now }); 307 308 expect(store.get("a")?.value).toBe("alpha"); 309 expect(store.get("b")?.value).toBe("beta"); 310 expect(store.get("c")?.value).toBe("gamma"); 311 312 store.delete("b"); 313 314 expect(store.get("a")?.value).toBe("alpha"); 315 expect(store.get("b")).toBeUndefined(); 316 expect(store.get("c")?.value).toBe("gamma"); 317 }); 318 }); 319});