forked from MergeFi/backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathidempotency.interceptor.spec.ts
More file actions
359 lines (306 loc) · 11.9 KB
/
Copy pathidempotency.interceptor.spec.ts
File metadata and controls
359 lines (306 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
import {
BadRequestException,
CallHandler,
ConflictException,
ExecutionContext,
InternalServerErrorException,
} from '@nestjs/common';
import { firstValueFrom, lastValueFrom, of, throwError } from 'rxjs';
import { IdempotencyKey } from '../entities/idempotency-key.entity';
import { IdempotencyKeyStatus } from '../enums';
import { IDEMPOTENCY_SCOPE_KEY } from './idempotent.decorator';
import { IdempotencyInterceptor } from './idempotency.interceptor';
const PG_UNIQUE_VIOLATION = '23505';
const STALE_PROCESSING_MS = 30 * 1000;
function uniqueViolationError(): Error & { driverError: { code: string } } {
return Object.assign(
new Error('duplicate key value violates unique constraint'),
{ driverError: { code: PG_UNIQUE_VIOLATION } },
);
}
function matches(
row: IdempotencyKey,
criteria: Partial<IdempotencyKey>,
): boolean {
return (Object.keys(criteria) as (keyof IdempotencyKey)[]).every((k) => {
const expected = criteria[k];
const actual = row[k];
if (expected instanceof Date) {
return actual instanceof Date && actual.getTime() === expected.getTime();
}
return actual === expected;
});
}
/**
* In-memory stand-in for Repository<IdempotencyKey> covering only the
* methods IdempotencyInterceptor calls. `insert` mimics the DB unique
* index on (key, scope, callerId) — the actual concurrency-safety
* primitive under test — with a synchronous check-and-push, which is
* enough to reproduce a real race: JS never preempts mid-microtask, so
* two `intercept()` calls racing to insert the same key interleave at
* `await` boundaries exactly like two connections racing a real unique
* constraint would.
*/
class FakeIdempotencyRepo {
rows: IdempotencyKey[] = [];
insertCalls = 0;
nextId = 1;
findOneBy(where: Partial<IdempotencyKey>): Promise<IdempotencyKey | null> {
return Promise.resolve(this.rows.find((r) => matches(r, where)) ?? null);
}
insert(data: Partial<IdempotencyKey>): Promise<void> {
this.insertCalls += 1;
const collision = this.rows.some(
(r) =>
r.key === data.key &&
r.scope === data.scope &&
r.callerId === data.callerId,
);
if (collision) {
return Promise.reject(uniqueViolationError());
}
const row = {
id: `row-${this.nextId++}`,
key: data.key,
scope: data.scope,
callerId: data.callerId,
status: IdempotencyKeyStatus.PROCESSING,
responseStatus: null,
responseBody: null,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: data.expiresAt,
} as IdempotencyKey;
this.rows.push(row);
return Promise.resolve();
}
update(
criteria: Partial<IdempotencyKey>,
partial: Partial<IdempotencyKey>,
): Promise<{ affected: number }> {
let affected = 0;
for (const row of this.rows) {
if (matches(row, criteria)) {
Object.assign(row, partial);
affected += 1;
}
}
return Promise.resolve({ affected });
}
delete(criteria: Partial<IdempotencyKey>): Promise<{ affected: number }> {
const before = this.rows.length;
this.rows = this.rows.filter((r) => !matches(r, criteria));
return Promise.resolve({ affected: before - this.rows.length });
}
}
const DUMMY_HANDLER = function dummyHandler() {};
function createContext(
overrides: {
headers?: Record<string, string>;
method?: string;
user?: { userId: string };
} = {},
): ExecutionContext {
const request = {
headers: overrides.headers ?? {},
method: overrides.method ?? 'POST',
user: overrides.user,
};
return {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => ({}),
}),
getHandler: () => DUMMY_HANDLER,
} as unknown as ExecutionContext;
}
function createNext(
factory: () => ReturnType<CallHandler['handle']>,
): CallHandler & { handle: jest.Mock } {
return { handle: jest.fn(factory) };
}
function createReflector(scope: string | undefined): { get: jest.Mock } {
return {
get: jest.fn((key: string) =>
key === IDEMPOTENCY_SCOPE_KEY ? scope : undefined,
),
};
}
const KEY_A = '11111111-1111-4111-8111-111111111111';
const KEY_B = '22222222-2222-4222-8222-222222222222';
describe('IdempotencyInterceptor', () => {
let repo: FakeIdempotencyRepo;
let reflector: { get: jest.Mock };
let interceptor: IdempotencyInterceptor;
beforeEach(() => {
repo = new FakeIdempotencyRepo();
reflector = createReflector('test.scope');
interceptor = new IdempotencyInterceptor(repo as never, reflector as never);
});
it('passes through untouched when the route carries no idempotency scope', async () => {
reflector = createReflector(undefined);
interceptor = new IdempotencyInterceptor(repo as never, reflector as never);
const next = createNext(() => of({ ok: true }));
const context = createContext();
const result = await lastValueFrom(
await interceptor.intercept(context, next),
);
expect(result).toEqual({ ok: true });
expect(next.handle).toHaveBeenCalledTimes(1);
expect(repo.insertCalls).toBe(0);
});
it('rejects with 400 when the Idempotency-Key header is missing', async () => {
const next = createNext(() => of({ ok: true }));
const context = createContext({ headers: {} });
await expect(interceptor.intercept(context, next)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(next.handle).not.toHaveBeenCalled();
});
it('rejects with 400 when the Idempotency-Key header is not a UUID', async () => {
const next = createNext(() => of({ ok: true }));
const context = createContext({ headers: { 'idempotency-key': 'nope' } });
await expect(interceptor.intercept(context, next)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(next.handle).not.toHaveBeenCalled();
});
it('executes the handler once and caches the result on first use', async () => {
const next = createNext(() => of({ id: 'bounty-1', status: 'funded' }));
const context = createContext({ headers: { 'idempotency-key': KEY_A } });
const observable = await interceptor.intercept(context, next);
const result = await lastValueFrom(observable);
expect(result).toEqual({ id: 'bounty-1', status: 'funded' });
expect(next.handle).toHaveBeenCalledTimes(1);
const row = repo.rows[0];
expect(row.status).toBe(IdempotencyKeyStatus.COMPLETED);
expect(row.responseBody).toEqual({ id: 'bounty-1', status: 'funded' });
});
it('replays the cached response on a duplicate key without re-executing the handler', async () => {
const next = createNext(() => of({ id: 'bounty-1', status: 'funded' }));
const context = createContext({ headers: { 'idempotency-key': KEY_A } });
await lastValueFrom(await interceptor.intercept(context, next));
const secondResult = await lastValueFrom(
await interceptor.intercept(context, next),
);
expect(secondResult).toEqual({ id: 'bounty-1', status: 'funded' });
expect(next.handle).toHaveBeenCalledTimes(1);
});
it('executes independently for different keys', async () => {
const next = createNext(() => of({ ok: true }));
const contextA = createContext({ headers: { 'idempotency-key': KEY_A } });
const contextB = createContext({ headers: { 'idempotency-key': KEY_B } });
await lastValueFrom(await interceptor.intercept(contextA, next));
await lastValueFrom(await interceptor.intercept(contextB, next));
expect(next.handle).toHaveBeenCalledTimes(2);
expect(repo.rows).toHaveLength(2);
});
it('does not cache a 5xx outcome, so a retry re-executes the handler', async () => {
let calls = 0;
const next = createNext(() => {
calls += 1;
if (calls === 1) {
return throwError(() => new InternalServerErrorException('boom'));
}
return of({ ok: true });
});
const context = createContext({ headers: { 'idempotency-key': KEY_A } });
await expect(
firstValueFrom(await interceptor.intercept(context, next)),
).rejects.toBeInstanceOf(InternalServerErrorException);
expect(repo.rows).toHaveLength(0);
const retryResult = await lastValueFrom(
await interceptor.intercept(context, next),
);
expect(retryResult).toEqual({ ok: true });
expect(next.handle).toHaveBeenCalledTimes(2);
});
it('caches a 4xx outcome and replays the same exception on retry without re-executing', async () => {
const next = createNext(() =>
throwError(() => new BadRequestException('insufficient funds')),
);
const context = createContext({ headers: { 'idempotency-key': KEY_A } });
await expect(
firstValueFrom(await interceptor.intercept(context, next)),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.rows).toHaveLength(1);
expect(repo.rows[0].status).toBe(IdempotencyKeyStatus.COMPLETED);
await expect(interceptor.intercept(context, next)).rejects.toMatchObject({
status: 400,
response: { message: 'insufficient funds' },
});
expect(next.handle).toHaveBeenCalledTimes(1);
});
it('rejects a concurrent duplicate-key request with 409 and only executes the handler once', async () => {
const next = createNext(() => of({ ok: true }));
const context = createContext({ headers: { 'idempotency-key': KEY_A } });
const first = interceptor.intercept(context, next);
const second = interceptor.intercept(context, next);
const [firstOutcome, secondOutcome] = await Promise.allSettled([
first,
second,
]);
expect(firstOutcome.status).toBe('fulfilled');
expect(secondOutcome.status).toBe('rejected');
if (secondOutcome.status === 'rejected') {
expect(secondOutcome.reason).toBeInstanceOf(ConflictException);
}
if (firstOutcome.status === 'fulfilled') {
await lastValueFrom(firstOutcome.value);
}
expect(next.handle).toHaveBeenCalledTimes(1);
expect(repo.rows).toHaveLength(1);
});
it('reclaims a stale PROCESSING row instead of 409-ing forever', async () => {
const context = createContext({ headers: { 'idempotency-key': KEY_A } });
await repo.insert({
key: KEY_A,
scope: 'test.scope',
callerId: 'anonymous',
expiresAt: new Date(Date.now() + 60_000),
});
// Simulate the original request having crashed mid-handler: back-date
// updatedAt past the staleness threshold instead of it being a
// genuinely in-flight request.
repo.rows[0].updatedAt = new Date(Date.now() - STALE_PROCESSING_MS - 1);
const next = createNext(() => of({ ok: true, reclaimed: true }));
const result = await lastValueFrom(
await interceptor.intercept(context, next),
);
expect(result).toEqual({ ok: true, reclaimed: true });
expect(next.handle).toHaveBeenCalledTimes(1);
expect(repo.rows[0].status).toBe(IdempotencyKeyStatus.COMPLETED);
});
it('rejects with 409 while a PROCESSING row is still fresh', async () => {
const context = createContext({ headers: { 'idempotency-key': KEY_A } });
await repo.insert({
key: KEY_A,
scope: 'test.scope',
callerId: 'anonymous',
expiresAt: new Date(Date.now() + 60_000),
});
const next = createNext(() => of({ ok: true }));
await expect(interceptor.intercept(context, next)).rejects.toBeInstanceOf(
ConflictException,
);
expect(next.handle).not.toHaveBeenCalled();
});
it('scopes keys per authenticated caller when req.user is present', async () => {
const next = createNext(() => of({ ok: true }));
const contextUser1 = createContext({
headers: { 'idempotency-key': KEY_A },
user: { userId: 'user-1' },
});
const contextUser2 = createContext({
headers: { 'idempotency-key': KEY_A },
user: { userId: 'user-2' },
});
await lastValueFrom(await interceptor.intercept(contextUser1, next));
await lastValueFrom(await interceptor.intercept(contextUser2, next));
expect(next.handle).toHaveBeenCalledTimes(2);
expect(repo.rows.map((r) => r.callerId).sort()).toEqual([
'user-1',
'user-2',
]);
});
});