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