1
10import { describe, expect, it } from "vitest";
11import { of, throwError } from "rxjs";
12import { IdempotencyInterceptor, IDEM_TTL_SECONDS } from "./idempotency.js";
13
14
15
16
17class FakeRedis {
18 private store = new Map<string, { value: string; expiresAt: number }>();
19 private nowMs = 0;
20 failLookups = false;
21 failStores = false;
22
23 getRedis() {
24 return {
25 get: async (k: string) => {
26 if (this.failLookups) throw new Error("redis down (lookup)");
27 const e = this.store.get(k);
28 if (!e) return null;
29 if (e.expiresAt !== Infinity && this.nowMs > e.expiresAt) {
30 this.store.delete(k);
31 return null;
32 }
33 return e.value;
34 },
35 set: async (k: string, v: string, mode: string, ttl: number, nx?: string) => {
36 if (this.failStores) throw new Error("redis down (store)");
37 if (mode !== "EX") throw new Error(`unexpected SET mode: ${mode}`);
38
39 if (nx === "NX") {
40 const existing = this.store.get(k);
41 if (existing && (existing.expiresAt === Infinity || this.nowMs <= existing.expiresAt)) {
42 return null;
43 }
44 }
45 this.store.set(k, { value: v, expiresAt: this.nowMs + ttl * 1000 });
46 return "OK";
47 },
48 };
49 }
50
51 advance(ms: number): void {
52 this.nowMs += ms;
53 }
54
55 storedBody(k: string): string | null {
56 const e = this.store.get(k);
57 if (!e) return null;
58 return (JSON.parse(e.value) as { body: string }).body;
59 }
60 storedTtlSeconds(k: string): number | null {
61 const e = this.store.get(k);
62 if (!e) return null;
63 return Math.round((e.expiresAt - this.nowMs) / 1000);
64 }
65}
66
67const DAY_MS = 24 * 60 * 60 * 1000;
68
69function makeCtx(
70 over: {
71 method?: string;
72 path?: string;
73 body?: unknown;
74 auth?: { kind?: string; userId?: string; apiKeyId?: string; workspaceId?: string } | undefined;
75 headers?: Record<string, string | string[] | undefined>;
76 statusCode?: number;
77 } = {},
78) {
79 const req = {
80 method: over.method ?? "POST",
81 path: over.path ?? "/workspaces/ws1/pages",
82 body: over.body,
83 auth: over.auth ?? { kind: "user", userId: "u1" },
84 headers: over.headers ?? {},
85 };
86 const headers: Record<string, string> = {};
87 const res = {
88 statusCode: over.statusCode ?? 200,
89 setHeader: (k: string, v: string) => {
90 headers[k] = v;
91 },
92 };
93 const context: any = {
94 switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
95 };
96 return { context, req, res, headers };
97}
98
99
101function callHandler(handler: () => unknown, error?: unknown): any {
102 return {
103 handle: () => (error ? throwError(() => error) : of(handler())),
104 };
105}
106
107function run(interceptor: IdempotencyInterceptor, ctx: any, handler: any): Promise<unknown> {
108 return new Promise((resolve, reject) => {
109 interceptor.intercept(ctx, handler).subscribe({ next: resolve, error: reject });
110 });
111}
112
113function makeInterceptor(redis: FakeRedis) {
114 return new IdempotencyInterceptor({ getRedis: () => redis.getRedis() } as never);
115}
116
117const KEY = "key_0123456789abcdef0123456789abcdef";
118
119
120
121describe("the idempotency middleware — 6-case matrix", () => {
122 it("1. a SEEN key (HIT) → the stored body is replayed, the handler is NOT re-run", async () => {
123 const redis = new FakeRedis();
124
125 await redis
126 .getRedis()
127 .set(
128 `idem:u1:${KEY}`,
129 JSON.stringify({ body: JSON.stringify({ id: "page_1", title: "Original" }) }),
130 "EX",
131 IDEM_TTL_SECONDS,
132 );
133 const interceptor = makeInterceptor(redis);
134 let handlerRan = false;
135 const { context, headers } = makeCtx({ headers: { "idempotency-key": KEY } });
136 const out = await run(
137 interceptor,
138 context,
139 callHandler(() => {
140 handlerRan = true;
141 return { id: "page_1", title: "SHOULD NOT HAPPEN" };
142 }),
143 );
144 expect(out).toEqual({ id: "page_1", title: "Original" });
145 expect(handlerRan).toBe(false);
146
147 expect(headers["Idempotency-Replayed"]).toBe("true");
148 });
149
150 it("2. a FRESH key (MISS) → the handler runs, a 2xx body is stored", async () => {
151 const redis = new FakeRedis();
152 const interceptor = makeInterceptor(redis);
153 const { context } = makeCtx({ headers: { "idempotency-key": KEY } });
154 const out = await run(
155 interceptor,
156 context,
157 callHandler(() => ({ id: "rec_1", title: "New" })),
158 );
159 expect(out).toEqual({ id: "rec_1", title: "New" });
160 expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(JSON.stringify({ id: "rec_1", title: "New" }));
161 expect(redis.storedTtlSeconds(`idem:u1:${KEY}`)).toBe(IDEM_TTL_SECONDS);
162 });
163
164 it("3. expiry — a key past its 24 h TTL reads as MISS (Redis enforces the TTL)", async () => {
165 const redis = new FakeRedis();
166
167 await redis
168 .getRedis()
169 .set(
170 `idem:u1:${KEY}`,
171 JSON.stringify({ body: JSON.stringify({ id: "page_1" }) }),
172 "EX",
173 IDEM_TTL_SECONDS,
174 );
175 redis.advance(DAY_MS + 1);
176 const interceptor = makeInterceptor(redis);
177 let handlerRan = false;
178 const { context } = makeCtx({ headers: { "idempotency-key": KEY } });
179 const out = await run(
180 interceptor,
181 context,
182 callHandler(() => {
183 handlerRan = true;
184 return { id: "page_2" };
185 }),
186 );
187 expect(handlerRan).toBe(true);
188 expect(out).toEqual({ id: "page_2" });
189 });
190
191 it("4. response storage — only a 2xx body is stored (a 4xx/5xx is rejected, not stored)", async () => {
192 const redis = new FakeRedis();
193 const interceptor = makeInterceptor(redis);
194
195 const { context: c409 } = makeCtx({ headers: { "idempotency-key": KEY }, statusCode: 409 });
196 await run(
197 interceptor,
198 c409,
199 callHandler(() => ({ error: "conflict" })),
200 );
201 expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null);
202
203 const { context: cErr } = makeCtx({ headers: { "idempotency-key": KEY } });
204 await expect(
205 run(
206 interceptor,
207 cErr,
208 callHandler(() => undefined, new Error("boom")),
209 ),
210 ).rejects.toThrow("boom");
211 expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null);
212 });
213
214 it("5. GET exclusion — a GET (even with a key) is a no-op (no Redis access, handler runs)", async () => {
215 const redis = new FakeRedis();
216 const lookups = 0;
217 const origGet = redis.getRedis().get;
218 const interceptor = makeInterceptor(redis);
219
220 const { context } = makeCtx({
221 method: "GET",
222 headers: { "idempotency-key": KEY },
223 });
224
225 const handler = callHandler(() => ({ data: "read" }));
226 const out = await run(interceptor, context, handler);
227 expect(out).toEqual({ data: "read" });
228 expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null);
229 void lookups;
230 void origGet;
231 });
232
233 it("6. no-header — a request without Idempotency-Key passes through untouched", async () => {
234 const redis = new FakeRedis();
235 let handlerRan = false;
236 const interceptor = makeInterceptor(redis);
237 const { context } = makeCtx({});
238 const out = await run(
239 interceptor,
240 context,
241 callHandler(() => {
242 handlerRan = true;
243 return { id: "rec_9" };
244 }),
245 );
246 expect(handlerRan).toBe(true);
247 expect(out).toEqual({ id: "rec_9" });
248 expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null);
249 });
250});
251
252
253
254describe("idempotency — auth scope + fail-open", () => {
255 it("an API-key caller (auth.kind !== user) is not honored (no keyspace write)", async () => {
256 const redis = new FakeRedis();
257 const interceptor = makeInterceptor(redis);
258 let handlerRan = false;
259 const { context } = makeCtx({
260 auth: { kind: "api" },
261 headers: { "idempotency-key": KEY },
262 });
263 await run(
264 interceptor,
265 context,
266 callHandler(() => {
267 handlerRan = true;
268 return { ok: true };
269 }),
270 );
271 expect(handlerRan).toBe(true);
272 expect(redis.storedBody(`idem:u1:${KEY}`)).toBe(null);
273 });
274
275 it("a key from ANOTHER user is not honored (the keyspace is user-scoped)", async () => {
276 const redis = new FakeRedis();
277
278 await redis
279 .getRedis()
280 .set(
281 `idem:u2:${KEY}`,
282 JSON.stringify({ body: JSON.stringify({ id: "u2_page" }) }),
283 "EX",
284 IDEM_TTL_SECONDS,
285 );
286 const interceptor = makeInterceptor(redis);
287 let handlerRan = false;
288 const { context } = makeCtx({
289 auth: { kind: "user", userId: "u1" },
290 headers: { "idempotency-key": KEY },
291 });
292 const out = await run(
293 interceptor,
294 context,
295 callHandler(() => {
296 handlerRan = true;
297 return { id: "u1_page" };
298 }),
299 );
300 expect(handlerRan).toBe(true);
301 expect(out).toEqual({ id: "u1_page" });
302 });
303
304 it("a Redis LOOKUP error is fail-open (execute, don't throw)", async () => {
305 const redis = new FakeRedis();
306 redis.failLookups = true;
307 const interceptor = makeInterceptor(redis);
308 let handlerRan = false;
309 const { context } = makeCtx({ headers: { "idempotency-key": KEY } });
310 const out = await run(
311 interceptor,
312 context,
313 callHandler(() => {
314 handlerRan = true;
315 return { id: "ok" };
316 }),
317 );
318 expect(handlerRan).toBe(true);
319 expect(out).toEqual({ id: "ok" });
320 });
321
322 it("a Redis STORE error is fail-open (the mutation still succeeds, no throw)", async () => {
323 const redis = new FakeRedis();
324 redis.failStores = true;
325 const interceptor = makeInterceptor(redis);
326 const { context } = makeCtx({ headers: { "idempotency-key": KEY } });
327 const out = await run(
328 interceptor,
329 context,
330 callHandler(() => ({ id: "ok" })),
331 );
332 expect(out).toEqual({ id: "ok" });
333 });
334
335 it("an EMPTY stored body (204 / nil) replays as undefined", async () => {
336 const redis = new FakeRedis();
337 await redis
338 .getRedis()
339 .set(`idem:u1:${KEY}`, JSON.stringify({ body: "__IDEM_EMPTY__" }), "EX", IDEM_TTL_SECONDS);
340 const interceptor = makeInterceptor(redis);
341 let handlerRan = false;
342 const { context } = makeCtx({ headers: { "idempotency-key": KEY } });
343 const out = await run(
344 interceptor,
345 context,
346 callHandler(() => {
347 handlerRan = true;
348 return "SHOULD NOT HAPPEN";
349 }),
350 );
351 expect(out).toBeUndefined();
352 expect(handlerRan).toBe(false);
353 });
354});
355