1
21import { describe, expect, it, beforeEach } from "vitest";
22import { z } from "zod";
23import { EventEmitter } from "node:events";
24import { CacheService, CACHE_TTLS } from "./cache.js";
25import { EVENTS } from "../constants.js";
26import { resetEnvCache } from "../config/env.js";
27
28
29
30
31
32interface FakeEntry {
33 value: string;
34 expireAt: number | null;
35}
36
37class FakeRedis {
38 store = new Map<string, FakeEntry>();
39 now = 0;
40 fail = false;
41
42 get nowMs(): number {
43 return this.now;
44 }
45
46 tick(ms: number): void {
47 this.now += ms;
48 for (const [k, v] of [...this.store]) if (v.expireAt !== null && v.expireAt <= this.now) this.store.delete(k);
49 }
50
51 private guard(): void {
52 if (this.fail) throw new Error("ECONNRESET (fake)");
53 }
54
55 async get(key: string): Promise<string | null> {
56 this.guard();
57 const e = this.store.get(key);
58 if (!e) return null;
59 if (e.expireAt !== null && e.expireAt <= this.now) {
60 this.store.delete(key);
61 return null;
62 }
63 return e.value;
64 }
65 async set(key: string, value: string, ...args: string[]): Promise<"OK"> {
66 this.guard();
67 let ttlMs: number | null = null;
68
69 for (let i = 0; i < args.length; i++) {
70 if (args[i] === "PX") ttlMs = Number(args[i + 1]);
71 }
72 this.store.set(key, { value, expireAt: ttlMs === null ? null : this.now + ttlMs });
73 return "OK";
74 }
75 async del(...keys: string[]): Promise<number> {
76 this.guard();
77 let n = 0;
78 for (const k of keys) if (this.store.delete(k)) n++;
79 return n;
80 }
81 async incr(key: string): Promise<number> {
82 this.guard();
83 const cur = this.store.get(key);
84 const n = (cur ? parseInt(cur.value, 10) : 0) + 1;
85 this.store.set(key, { value: String(n), expireAt: null });
86 return n;
87 }
88 async scan(cursor: string, ...args: string[]): Promise<[string, string[]]> {
89 this.guard();
90 let pattern = "*";
91 for (let i = 0; i < args.length; i++) if (args[i] === "MATCH") pattern = args[i + 1];
92 const rx = new RegExp("^" + pattern.split("*").map(escapeRe).join(".*") + "$");
93 const keys: string[] = [];
94 for (const [k, v] of this.store) {
95 if (v.expireAt !== null && v.expireAt <= this.now) continue;
96 if (rx.test(k)) keys.push(k);
97 }
98 return ["0", keys];
99 }
100}
101
102function escapeRe(s: string): string {
103 return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
104}
105
106
107class FakeEvents {
108 private emitter = new EventEmitter();
109 onLocal(event: string, handler: (body: { event: string; payload: string }) => void): () => void {
110 this.emitter.on(event, handler);
111 return () => this.emitter.off(event, handler);
112 }
113 async publish(event: string, payload: Record<string, unknown>): Promise<void> {
114 this.emitter.emit(event, { event, payload: JSON.stringify(payload) });
115 }
116}
117
118const LEVEL = z.enum(["NONE", "VIEW", "COMMENT", "EDIT", "FULL_ACCESS"]);
119const TREE_ITEM = z.object({ id: z.string(), position: z.string() });
120
121interface Deps {
122 cache: CacheService;
123 redis: FakeRedis;
124 events: FakeEvents;
125}
126
127
133async function build(): Promise<Deps> {
134 const prev = process.env.PERF_CACHE;
135 process.env.PERF_CACHE = "on";
136 resetEnvCache();
137 try {
138 const redis = new FakeRedis();
139 const events = new FakeEvents();
140 const fakeRedisService = { getRedis: () => redis as unknown as import("ioredis").default } as unknown as {
141 getRedis(): import("ioredis").default;
142 };
143 const cache = new CacheService(fakeRedisService as never, events as never);
144 cache.onModuleInit();
145 return { cache, redis, events };
146 } finally {
147 if (prev === undefined) delete process.env.PERF_CACHE;
148 else process.env.PERF_CACHE = prev;
149 resetEnvCache();
150 }
151}
152
153describe("cache (TASK-0135 invalidation semantics)", () => {
154 beforeEach(() => {
155
156 delete process.env.PERF_CACHE;
157 });
158
159 describe("enablement gate (PERF_CACHE)", () => {
160 it("auto (default) is OFF outside production → pass-through, no Redis writes", async () => {
161 const prev = process.env.PERF_CACHE;
162 const prevNode = process.env.NODE_ENV;
163 process.env.PERF_CACHE = "auto";
164 process.env.NODE_ENV = "test";
165 resetEnvCache();
166 try {
167 const redis = new FakeRedis();
168 const events = new FakeEvents();
169 const cache = new CacheService({ getRedis: () => redis as never } as never, events as never);
170 cache.onModuleInit();
171 expect(cache.isOn()).toBe(false);
172 const { data, cached } = await cache.getMetadata("db1", "properties", async () => ({ ok: true }), z.object({ ok: z.boolean() }));
173 expect(cached).toBe(false);
174 expect(data).toEqual({ ok: true });
175 expect(redis.store.size).toBe(0);
176 } finally {
177 if (prev === undefined) delete process.env.PERF_CACHE;
178 else process.env.PERF_CACHE = prev;
179 if (prevNode === undefined) delete process.env.NODE_ENV;
180 else process.env.NODE_ENV = prevNode;
181 resetEnvCache();
182 }
183 });
184
185 it("`on` is ON regardless of NODE_ENV", async () => {
186 const prev = process.env.PERF_CACHE;
187 process.env.PERF_CACHE = "on";
188 resetEnvCache();
189 try {
190 const redis = new FakeRedis();
191 const events = new FakeEvents();
192 const cache = new CacheService({ getRedis: () => redis as never } as never, events as never);
193 cache.onModuleInit();
194 expect(cache.isOn()).toBe(true);
195 } finally {
196 if (prev === undefined) delete process.env.PERF_CACHE;
197 else process.env.PERF_CACHE = prev;
198 resetEnvCache();
199 }
200 });
201 });
202
203 describe("tree (version-key) freshness", () => {
204 it("a write that bumps the version invalidates the previous key (no stale read)", async () => {
205 const { cache, events, redis } = await build();
206 const ws = "ws1";
207 const loads: string[] = [];
208 const loader = async () => {
209 loads.push("load");
210 return [{ id: "b1", position: "a0" }, { id: "b2", position: "a1" }];
211 };
212
213
214
215 await cache.bumpVersion(ws);
216 const r1 = await cache.getTree(ws, "p1", loader, z.array(TREE_ITEM));
217 expect(r1.cached).toBe(false);
218 expect(loads).toHaveLength(1);
219
220
221 const r2 = await cache.getTree(ws, "p1", loader, z.array(TREE_ITEM));
222 expect(r2.cached).toBe(true);
223 expect(loads).toHaveLength(1);
224
225
226 await events.publish(EVENTS.PAGE_UPDATED, { workspaceId: ws, pageId: "p1" });
227
228 await new Promise((r) => setTimeout(r, 10));
229 expect(await cache.getVersion(ws)).toBe(2);
230
231
232 const r3 = await cache.getTree(ws, "p1", loader, z.array(TREE_ITEM));
233 expect(r3.cached).toBe(false);
234 expect(loads).toHaveLength(2);
235
236
237 expect(redis.store.has("perf:tree:ws1:p1:1")).toBe(true);
238 expect(redis.store.has("perf:tree:ws1:p1:2")).toBe(true);
239 });
240
241 it("version 0 (no write yet / Redis cold) never caches — always loads", async () => {
242 const { cache } = await build();
243 const loads: string[] = [];
244 const loader = async () => {
245 loads.push("x");
246 return [{ id: "b1", position: "a0" }];
247 };
248 const r = await cache.getTree("wsX", "p1", loader, z.array(TREE_ITEM));
249 expect(r.cached).toBe(false);
250 expect(loads).toHaveLength(1);
251 const r2 = await cache.getTree("wsX", "p1", loader, z.array(TREE_ITEM));
252 expect(r2.cached).toBe(false);
253 expect(loads).toHaveLength(2);
254 });
255 });
256
257 describe("permission (TTL 60 s + bust)", () => {
258 it("caches a decision and serves it within the TTL (no event → stale ≤ 60 s)", async () => {
259 const { cache, redis } = await build();
260 let loads = 0;
261 const loader = async () => {
262 loads++;
263 return "EDIT" as const;
264 };
265 const scope = { pageId: "p1" };
266 const r1 = await cache.getPermission("ws1", "u1", scope, loader, LEVEL);
267 expect(r1.cached).toBe(false);
268 expect(loads).toBe(1);
269 const r2 = await cache.getPermission("ws1", "u1", scope, loader, LEVEL);
270 expect(r2.cached).toBe(true);
271 expect(loads).toBe(1);
272
273
274
275 expect(CACHE_TTLS.PERMISSION_MS).toBe(60_000);
276 const key = [...redis.store.keys()].find((k) => k.startsWith("perf:perm:ws1:u1:"));
277 expect(key).toBeDefined();
278 redis.tick(CACHE_TTLS.PERMISSION_MS - 100);
279 const stillLive = (await redis.get(key!)) !== null;
280 expect(stillLive).toBe(true);
281 redis.tick(200);
282 const r3 = await cache.getPermission("ws1", "u1", scope, loader, LEVEL);
283 expect(r3.cached).toBe(false);
284 expect(loads).toBe(2);
285 });
286
287 it("a permission.changed event busts the subject's entries → next decision fresh", async () => {
288 const { cache, events } = await build();
289 let loads = 0;
290 const loader = async () => {
291 loads++;
292 return (loads === 1 ? "EDIT" : "NONE") as "EDIT" | "NONE";
293 };
294 const scope = { pageId: "p1" };
295 const r1 = await cache.getPermission("ws1", "u2", scope, loader, LEVEL);
296 expect(r1.level).toBe("EDIT");
297
298 await events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId: "ws1", userId: "u2", removed: true });
299 await new Promise((r) => setTimeout(r, 15));
300
301 const r2 = await cache.getPermission("ws1", "u2", scope, loader, LEVEL);
302 expect(r2.cached).toBe(false);
303 expect(r2.level).toBe("NONE");
304 expect(loads).toBe(2);
305 });
306
307 it("different users never share a permission entry", async () => {
308 const { cache, redis } = await build();
309 const scope = { pageId: "p1" };
310 await cache.getPermission("ws1", "uA", scope, async () => "EDIT" as const, LEVEL);
311 await cache.getPermission("ws1", "uB", scope, async () => "NONE" as const, LEVEL);
312 const keys = [...redis.store.keys()].filter((k) => k.startsWith("perf:perm:ws1:"));
313 expect(keys).toHaveLength(2);
314 });
315 });
316
317 describe("search (TTL 5 s, permission-keyed)", () => {
318 it("caches for exactly 5 s and is keyed by principal (no cross-principal sharing)", async () => {
319 const { cache, redis } = await build();
320 expect(CACHE_TTLS.SEARCH_MS).toBe(5_000);
321 let loads = 0;
322 const loader = async () => {
323 loads++;
324 return { data: ["hit"] };
325 };
326 const input = { q: "notion", types: ["page"], workspaceId: "ws1", cursor: null, limit: 20 };
327 const r1 = await cache.getSearch("ws1", "principal-A", input, loader, z.object({ data: z.array(z.string()) }));
328 expect(r1.cached).toBe(false);
329 const r2 = await cache.getSearch("ws1", "principal-A", input, loader, z.object({ data: z.array(z.string()) }));
330 expect(r2.cached).toBe(true);
331 expect(loads).toBe(1);
332
333
334
335 const r3 = await cache.getSearch("ws1", "principal-B", input, loader, z.object({ data: z.array(z.string()) }));
336 expect(r3.cached).toBe(false);
337 expect(loads).toBe(2);
338 const keys = [...redis.store.keys()].filter((k) => k.startsWith("perf:search:ws1:"));
339 expect(keys).toHaveLength(2);
340
341
342 const keyA = keys.find((k) => k.includes("principal-A"))!;
343 redis.tick(4_900);
344 expect(await redis.get(keyA)).not.toBeNull();
345 redis.tick(300);
346 const r4 = await cache.getSearch("ws1", "principal-A", input, loader, z.object({ data: z.array(z.string()) }));
347 expect(r4.cached).toBe(false);
348 expect(loads).toBe(3);
349 });
350 });
351
352 describe("metadata (TTL 60 s + bust on database.updated)", () => {
353 it("busts on database.updated → next read is fresh", async () => {
354 const { cache, events } = await build();
355 let loads = 0;
356 const loader = async () => {
357 loads++;
358 return loads === 1 ? [{ id: "prop1" }] : [{ id: "prop1" }, { id: "prop2" }];
359 };
360 const schema = z.array(z.object({ id: z.string() }));
361 const r1 = await cache.getMetadata("db1", "properties", loader, schema);
362 expect(r1.cached).toBe(false);
363 expect((r1.data as { id: string }[])).toHaveLength(1);
364
365 await events.publish(EVENTS.DATABASE_UPDATED, { workspaceId: "ws1", databaseId: "db1", propertyId: "prop2" });
366 await new Promise((r) => setTimeout(r, 10));
367 const r2 = await cache.getMetadata("db1", "properties", loader, schema);
368 expect(r2.cached).toBe(false);
369 expect((r2.data as { id: string }[])).toHaveLength(2);
370 expect(loads).toBe(2);
371 });
372 });
373
374 describe("zod-on-read: corrupt entry = miss, never a throw", () => {
375 it("corrupt JSON is a miss (and is replaced with a valid entry), never a throw", async () => {
376 const { cache, redis } = await build();
377
378 const key = "perf:metadata:db1:properties";
379 await redis.set(key, "{not-json", "PX", "60000");
380 const { data, cached } = await cache.getMetadata("db1", "properties", async () => ({ ok: true }), z.object({ ok: z.boolean() }));
381 expect(cached).toBe(false);
382 expect(data).toEqual({ ok: true });
383
384
385 const nowStored = await redis.get(key);
386 expect(nowStored).toBe(JSON.stringify({ ok: true }));
387 });
388
389 it("a shape mismatch is a miss (and is replaced with a valid entry), never a throw", async () => {
390 const { cache, redis } = await build();
391 const key = "perf:metadata:db1:properties";
392 await redis.set(key, JSON.stringify(["wrong-shape"]), "PX", "60000");
393 const { data, cached } = await cache.getMetadata("db1", "properties", async () => ({ ok: true }), z.object({ ok: z.boolean() }));
394 expect(cached).toBe(false);
395 expect(data).toEqual({ ok: true });
396 const nowStored = await redis.get(key);
397 expect(nowStored).toBe(JSON.stringify({ ok: true }));
398 });
399
400 it("a corrupt permission entry is a miss, not a thrown 500", async () => {
401 const { cache, redis } = await build();
402
403 const scope = { pageId: "p1" };
404 await cache.getPermission("ws1", "u9", scope, async () => "EDIT" as const, LEVEL);
405 const key = [...redis.store.keys()].find((k) => k.startsWith("perf:perm:ws1:u9:"))!;
406 await redis.set(key, '"BOGUS-LEVEL"', "PX", "60000");
407 const { level, cached } = await cache.getPermission("ws1", "u9", scope, async () => "VIEW" as const, LEVEL);
408 expect(cached).toBe(false);
409 expect(level).toBe("VIEW");
410 });
411 });
412
413 describe("Redis failure degrades to pass-through (never a 500)", () => {
414 it("a Redis blip makes reads fall through to the loader without throwing", async () => {
415 const { cache, redis } = await build();
416 const loads: string[] = [];
417 const loader = async () => {
418 loads.push("x");
419 return [{ id: "b1", position: "a0" }];
420 };
421 await cache.bumpVersion("ws1");
422 redis.fail = true;
423 const { data, cached } = await cache.getTree("ws1", "p1", loader, z.array(TREE_ITEM));
424 expect(cached).toBe(false);
425 expect(loads).toHaveLength(1);
426 expect(data).toHaveLength(1);
427 redis.fail = false;
428 });
429 });
430});
431