@jintianxiayu/cache-decorator 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -11
- package/README.md +364 -270
- package/dist/core/cache-error.d.ts +75 -0
- package/dist/core/cache-error.d.ts.map +1 -0
- package/dist/core/cache-error.js +168 -0
- package/dist/core/cache-error.js.map +1 -0
- package/dist/core/cache-logger.d.ts +3 -2
- package/dist/core/cache-logger.d.ts.map +1 -1
- package/dist/core/cache-logger.js +2 -0
- package/dist/core/cache-logger.js.map +1 -1
- package/dist/decorators/cache-evict.d.ts.map +1 -1
- package/dist/decorators/cache-evict.js +15 -10
- package/dist/decorators/cache-evict.js.map +1 -1
- package/dist/decorators/cache.d.ts +6 -0
- package/dist/decorators/cache.d.ts.map +1 -1
- package/dist/decorators/cache.js +113 -36
- package/dist/decorators/cache.js.map +1 -1
- package/jest.config.js +11 -11
- package/package.json +1 -1
- package/src/adapters/ioredis-cache-client.ts +89 -89
- package/src/adapters/node-redis-cache-client.ts +95 -95
- package/src/adapters/redis-key-prefix.ts +38 -38
- package/src/core/cache-error.ts +233 -0
- package/src/core/cache-logger.ts +116 -105
- package/src/core/key-builder.ts +33 -33
- package/src/core/native-cache.ts +55 -55
- package/src/core/pending-cache.ts +29 -29
- package/src/core/redis-cache-client.ts +160 -160
- package/src/core/redis-cache.ts +104 -104
- package/src/decorators/cache-evict.ts +137 -129
- package/src/decorators/cache.ts +323 -203
- package/src/index.ts +11 -11
- package/test/cache-evict-logging.test.ts +398 -362
- package/test/cache-logger.integration.test.ts +159 -129
- package/test/cache-logger.test.ts +156 -153
- package/test/cache-logging.test.ts +929 -544
- package/test/cache.test.ts +1017 -255
- package/test/fixtures/cache-logger-child.mjs +108 -108
- package/test/fixtures/cache-provider-failure-child.mjs +70 -0
- package/test/helpers/legacy-redis-cache.ts +41 -22
- package/test/helpers/package-consumer.ts +231 -231
- package/test/helpers/redis-fixture.ts +142 -142
- package/test/ioredis-cache-client.test.ts +143 -143
- package/test/legacy-redis-cache.test.ts +51 -0
- package/test/native-cache.test.ts +77 -77
- package/test/node-redis-cache-client.test.ts +149 -149
- package/test/pending-cache.test.ts +69 -69
- package/test/redis-cache-client-lifecycle.test.ts +112 -112
- package/test/redis-cache-client-types.test.ts +184 -184
- package/test/redis-cache-client.integration.test.ts +355 -327
- package/test/redis-cache-decorator.test.ts +601 -201
- package/test/redis-cache-provider.test.ts +269 -269
- package/test/type-contract/contract.ts +119 -64
- package/test/type-contract/tsconfig.json +12 -12
- package/tsconfig.json +8 -8
|
@@ -1,544 +1,929 @@
|
|
|
1
|
-
jest.mock('@jintianxiayu/logger', () => ({
|
|
2
|
-
LoggerFactory: {
|
|
3
|
-
getLogger: jest.fn(),
|
|
4
|
-
},
|
|
5
|
-
}));
|
|
6
|
-
|
|
7
|
-
import { LoggerFactory, type LoggerInterface } from '@jintianxiayu/logger';
|
|
8
|
-
import { Cache } from '../src/decorators/cache';
|
|
9
|
-
import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
|
|
10
|
-
import { KeyBuilder } from '../src/core/key-builder';
|
|
11
|
-
import type { CacheProvider } from '../src/core/cache-provider';
|
|
12
|
-
|
|
13
|
-
interface ProviderHarness {
|
|
14
|
-
readonly provider: CacheProvider;
|
|
15
|
-
readonly get: jest.Mock<unknown, [string]>;
|
|
16
|
-
readonly set: jest.Mock<unknown, [string, unknown, number?]>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface CacheLogMetadata {
|
|
20
|
-
readonly event: string;
|
|
21
|
-
readonly cacheName: string;
|
|
22
|
-
readonly methodName: string;
|
|
23
|
-
readonly providerName: string;
|
|
24
|
-
readonly entryType?: string;
|
|
25
|
-
readonly reason?: string;
|
|
26
|
-
readonly
|
|
27
|
-
readonly
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
readonly
|
|
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
|
-
const
|
|
59
|
-
const
|
|
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
|
-
const
|
|
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
|
-
providerName: 'value-provider'
|
|
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
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const
|
|
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
|
-
provider.set.
|
|
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
|
-
expect(loggedMetadata(mockLogger.error)).
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it('cache-operation-logging/
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
providerName: '
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
expect(
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
}
|
|
541
|
-
expect(
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1
|
+
jest.mock('@jintianxiayu/logger', () => ({
|
|
2
|
+
LoggerFactory: {
|
|
3
|
+
getLogger: jest.fn(),
|
|
4
|
+
},
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
import { LoggerFactory, type LoggerInterface } from '@jintianxiayu/logger';
|
|
8
|
+
import { Cache } from '../src/decorators/cache';
|
|
9
|
+
import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
|
|
10
|
+
import { KeyBuilder } from '../src/core/key-builder';
|
|
11
|
+
import type { CacheProvider } from '../src/core/cache-provider';
|
|
12
|
+
|
|
13
|
+
interface ProviderHarness {
|
|
14
|
+
readonly provider: CacheProvider;
|
|
15
|
+
readonly get: jest.Mock<unknown, [string]>;
|
|
16
|
+
readonly set: jest.Mock<unknown, [string, unknown, number?]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CacheLogMetadata {
|
|
20
|
+
readonly event: string;
|
|
21
|
+
readonly cacheName: string;
|
|
22
|
+
readonly methodName: string;
|
|
23
|
+
readonly providerName: string;
|
|
24
|
+
readonly entryType?: string;
|
|
25
|
+
readonly reason?: string;
|
|
26
|
+
readonly phase?: string;
|
|
27
|
+
readonly operation?: string;
|
|
28
|
+
readonly error?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Deferred<Value> {
|
|
32
|
+
readonly promise: Promise<Value>;
|
|
33
|
+
readonly resolve: (value: Value) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mockLogger: jest.Mocked<LoggerInterface> = {
|
|
37
|
+
debug: jest.fn(),
|
|
38
|
+
info: jest.fn(),
|
|
39
|
+
warn: jest.fn(),
|
|
40
|
+
error: jest.fn(),
|
|
41
|
+
};
|
|
42
|
+
const mockGetLogger = jest.mocked(LoggerFactory.getLogger);
|
|
43
|
+
|
|
44
|
+
/** 为并发请求测试创建由用例显式完成的 Promise。 */
|
|
45
|
+
function createDeferred<Value>(): Deferred<Value> {
|
|
46
|
+
let resolvePromise: ((value: Value) => void) | undefined;
|
|
47
|
+
const promise = new Promise<Value>((resolve) => {
|
|
48
|
+
resolvePromise = resolve;
|
|
49
|
+
});
|
|
50
|
+
if (!resolvePromise) {
|
|
51
|
+
throw new Error('Deferred resolver was not initialized');
|
|
52
|
+
}
|
|
53
|
+
return { promise, resolve: resolvePromise };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 创建仅记录 decorator 实际调用次数和参数的可插拔 Provider。 */
|
|
57
|
+
function createProvider(getResult: unknown): ProviderHarness {
|
|
58
|
+
const get = jest.fn<unknown, [string]>(() => getResult);
|
|
59
|
+
const set = jest.fn<unknown, [string, unknown, number?]>();
|
|
60
|
+
const provider: CacheProvider = {
|
|
61
|
+
get: <Value>(key: string): Value | undefined | Promise<Value | undefined> =>
|
|
62
|
+
get(key) as Value | undefined | Promise<Value | undefined>,
|
|
63
|
+
set: <Value>(key: string, value: Value, ttl?: number): void | Promise<void> =>
|
|
64
|
+
set(key, value, ttl) as void | Promise<void>,
|
|
65
|
+
delete: (_key: string): void => {
|
|
66
|
+
return;
|
|
67
|
+
},
|
|
68
|
+
clear: (): void => {
|
|
69
|
+
return;
|
|
70
|
+
},
|
|
71
|
+
deleteByPattern: (_pattern: string): void => {
|
|
72
|
+
return;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
return { provider, get, set };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loggedMetadata(method: jest.Mock): CacheLogMetadata[] {
|
|
79
|
+
return method.mock.calls.map((call) => call[1] as CacheLogMetadata);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function events(method: jest.Mock): string[] {
|
|
83
|
+
return loggedMetadata(method).map(({ event }) => event);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 使用公开协议形状构造当前版本异常条目,避免日志测试依赖内部 helper。 */
|
|
87
|
+
function currentErrorEntry(payload: unknown): unknown {
|
|
88
|
+
return {
|
|
89
|
+
error: {
|
|
90
|
+
kind: '@jintianxiayu/cache-decorator/error',
|
|
91
|
+
version: 1,
|
|
92
|
+
payload,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
jest.clearAllMocks();
|
|
99
|
+
mockGetLogger.mockReturnValue(mockLogger);
|
|
100
|
+
CacheProviderRegistry.clear();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
jest.restoreAllMocks();
|
|
105
|
+
CacheProviderRegistry.clear();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('cache-operation-logging/C01 cache-operation-logging/A02 并发调用复用同一 pending Promise', async () => {
|
|
109
|
+
const provider = createProvider(undefined);
|
|
110
|
+
CacheProviderRegistry.register('pending-provider', provider.provider);
|
|
111
|
+
CacheProviderRegistry.setDefault('pending-provider');
|
|
112
|
+
const result = createDeferred<number>();
|
|
113
|
+
const businessMethod = jest.fn(() => result.promise);
|
|
114
|
+
|
|
115
|
+
class UserService {
|
|
116
|
+
@Cache('pending-users')
|
|
117
|
+
getUser(id: number): Promise<number> {
|
|
118
|
+
return businessMethod(id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const service = new UserService();
|
|
123
|
+
const first = service.getUser(7);
|
|
124
|
+
const second = service.getUser(7);
|
|
125
|
+
|
|
126
|
+
expect(second).toBe(first);
|
|
127
|
+
expect(provider.get).toHaveBeenCalledTimes(1);
|
|
128
|
+
await Promise.resolve();
|
|
129
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
130
|
+
result.resolve(42);
|
|
131
|
+
await expect(Promise.all([first, second])).resolves.toEqual([42, 42]);
|
|
132
|
+
expect(events(mockLogger.debug).filter((event) => event === 'cache.pending_hit')).toHaveLength(1);
|
|
133
|
+
expect(events(mockLogger.debug).filter((event) => event === 'cache.miss')).toHaveLength(1);
|
|
134
|
+
expect(events(mockLogger.debug)).not.toContain('cache.hit');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('cache-operation-logging/C02 命中 value entry 时记录 hit 并跳过业务方法', async () => {
|
|
138
|
+
const cachedValue = { id: 7, source: 'cache' };
|
|
139
|
+
const provider = createProvider({ value: cachedValue });
|
|
140
|
+
CacheProviderRegistry.register('value-provider', provider.provider);
|
|
141
|
+
const businessMethod = jest.fn();
|
|
142
|
+
|
|
143
|
+
class UserService {
|
|
144
|
+
@Cache('value-users', { providerName: 'value-provider' })
|
|
145
|
+
getUser(): unknown {
|
|
146
|
+
return businessMethod();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
await expect(new UserService().getUser()).resolves.toBe(cachedValue);
|
|
151
|
+
expect(businessMethod).not.toHaveBeenCalled();
|
|
152
|
+
expect(loggedMetadata(mockLogger.debug)).toContainEqual({
|
|
153
|
+
event: 'cache.hit',
|
|
154
|
+
cacheName: 'value-users',
|
|
155
|
+
methodName: 'getUser',
|
|
156
|
+
providerName: 'value-provider',
|
|
157
|
+
entryType: 'value',
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('cache-operation-logging/C03 命中可解码的版本化 error entry 时记录 hit', async () => {
|
|
162
|
+
const provider = createProvider(
|
|
163
|
+
currentErrorEntry({ type: 'error', name: 'CachedError', message: 'cached business failure' })
|
|
164
|
+
);
|
|
165
|
+
CacheProviderRegistry.register('error-entry-provider', provider.provider);
|
|
166
|
+
const businessMethod = jest.fn();
|
|
167
|
+
|
|
168
|
+
class UserService {
|
|
169
|
+
@Cache('error-users', { providerName: 'error-entry-provider', errorCache: { ttl: 5 } })
|
|
170
|
+
getUser(): unknown {
|
|
171
|
+
return businessMethod();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await expect(new UserService().getUser()).rejects.toMatchObject({
|
|
176
|
+
name: 'CachedError',
|
|
177
|
+
message: 'cached business failure',
|
|
178
|
+
});
|
|
179
|
+
expect(businessMethod).not.toHaveBeenCalled();
|
|
180
|
+
expect(loggedMetadata(mockLogger.debug)).toContainEqual({
|
|
181
|
+
event: 'cache.hit',
|
|
182
|
+
cacheName: 'error-users',
|
|
183
|
+
methodName: 'getUser',
|
|
184
|
+
providerName: 'error-entry-provider',
|
|
185
|
+
entryType: 'error',
|
|
186
|
+
});
|
|
187
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('cache-operation-logging/K01 resolver 失败时记录无敏感数据的 fallback 并使用默认 key', async () => {
|
|
191
|
+
const secretArgument = { phone: '13800138000', token: 'credential' };
|
|
192
|
+
const provider = createProvider({ value: 'cached' });
|
|
193
|
+
CacheProviderRegistry.register('fallback-provider', provider.provider);
|
|
194
|
+
const resolver = jest.fn(() => {
|
|
195
|
+
throw new Error('resolver leaked secret');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
class UserService {
|
|
199
|
+
@Cache('fallback-users', { providerName: 'fallback-provider', key: resolver })
|
|
200
|
+
getUser(_input: unknown): string {
|
|
201
|
+
return 'business';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await expect(new UserService().getUser(secretArgument)).resolves.toBe('cached');
|
|
206
|
+
expect(resolver).toHaveBeenCalledTimes(1);
|
|
207
|
+
expect(provider.get).toHaveBeenCalledWith(KeyBuilder.build('fallback-users', [secretArgument]));
|
|
208
|
+
const fallback = loggedMetadata(mockLogger.warn).find(({ event }) => event === 'cache.key_fallback');
|
|
209
|
+
expect(fallback).toEqual({
|
|
210
|
+
event: 'cache.key_fallback',
|
|
211
|
+
cacheName: 'fallback-users',
|
|
212
|
+
methodName: 'getUser',
|
|
213
|
+
providerName: 'fallback-provider',
|
|
214
|
+
reason: 'resolver_error',
|
|
215
|
+
});
|
|
216
|
+
expect(JSON.stringify(fallback)).not.toContain('13800138000');
|
|
217
|
+
expect(JSON.stringify(fallback)).not.toContain('credential');
|
|
218
|
+
expect(JSON.stringify(fallback)).not.toContain('resolver leaked secret');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('cache-operation-logging/C04 miss 后回填 value entry 并记录 dispatched', async () => {
|
|
222
|
+
const provider = createProvider(undefined);
|
|
223
|
+
CacheProviderRegistry.register('write-value-provider', provider.provider);
|
|
224
|
+
const businessResult = { id: 8 };
|
|
225
|
+
const businessMethod = jest.fn(() => businessResult);
|
|
226
|
+
|
|
227
|
+
class UserService {
|
|
228
|
+
@Cache('write-value-users', { providerName: 'write-value-provider', ttl: 30 })
|
|
229
|
+
getUser(): { id: number } {
|
|
230
|
+
return businessMethod();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
await expect(new UserService().getUser()).resolves.toBe(businessResult);
|
|
235
|
+
expect(provider.get).toHaveBeenCalledTimes(1);
|
|
236
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
237
|
+
expect(provider.set).toHaveBeenCalledWith(expect.any(String), { value: businessResult }, 30);
|
|
238
|
+
expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched']);
|
|
239
|
+
expect(loggedMetadata(mockLogger.debug)[1]).toEqual({
|
|
240
|
+
event: 'cache.write_dispatched',
|
|
241
|
+
cacheName: 'write-value-users',
|
|
242
|
+
methodName: 'getUser',
|
|
243
|
+
providerName: 'write-value-provider',
|
|
244
|
+
entryType: 'value',
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('cache-operation-logging/C05 业务异常回填 error entry 后抛出同一异常', async () => {
|
|
249
|
+
const provider = createProvider(undefined);
|
|
250
|
+
CacheProviderRegistry.register('write-error-provider', provider.provider);
|
|
251
|
+
const businessError = new Error('business failed');
|
|
252
|
+
const businessMethod = jest.fn(() => {
|
|
253
|
+
throw businessError;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
class UserService {
|
|
257
|
+
@Cache('write-error-users', { providerName: 'write-error-provider', errorCache: { ttl: 7 } })
|
|
258
|
+
getUser(): never {
|
|
259
|
+
return businessMethod();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
await expect(new UserService().getUser()).rejects.toBe(businessError);
|
|
264
|
+
expect(provider.get).toHaveBeenCalledTimes(1);
|
|
265
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
266
|
+
expect(provider.set).toHaveBeenCalledWith(
|
|
267
|
+
expect.any(String),
|
|
268
|
+
currentErrorEntry({ type: 'error', name: 'Error', message: 'business failed' }),
|
|
269
|
+
7
|
|
270
|
+
);
|
|
271
|
+
expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched']);
|
|
272
|
+
expect(loggedMetadata(mockLogger.debug)[1]).toEqual({
|
|
273
|
+
event: 'cache.write_dispatched',
|
|
274
|
+
cacheName: 'write-error-users',
|
|
275
|
+
methodName: 'getUser',
|
|
276
|
+
providerName: 'write-error-provider',
|
|
277
|
+
entryType: 'error',
|
|
278
|
+
});
|
|
279
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('cache-operation-logging/C06 回填后的重复调用命中缓存且不重复执行业务', async () => {
|
|
283
|
+
let storedEntry: unknown;
|
|
284
|
+
const provider = createProvider(undefined);
|
|
285
|
+
provider.get.mockImplementation(() => storedEntry);
|
|
286
|
+
provider.set.mockImplementation((_key, entry) => {
|
|
287
|
+
storedEntry = entry;
|
|
288
|
+
});
|
|
289
|
+
CacheProviderRegistry.register('repeat-provider', provider.provider);
|
|
290
|
+
const businessResult = { id: 9 };
|
|
291
|
+
const businessMethod = jest.fn(() => businessResult);
|
|
292
|
+
|
|
293
|
+
class UserService {
|
|
294
|
+
@Cache('repeat-users', { providerName: 'repeat-provider' })
|
|
295
|
+
getUser(): { id: number } {
|
|
296
|
+
return businessMethod();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const service = new UserService();
|
|
301
|
+
await expect(service.getUser()).resolves.toBe(businessResult);
|
|
302
|
+
await expect(service.getUser()).resolves.toBe(businessResult);
|
|
303
|
+
|
|
304
|
+
expect(provider.get).toHaveBeenCalledTimes(2);
|
|
305
|
+
expect(provider.set).toHaveBeenCalledTimes(1);
|
|
306
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
307
|
+
expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched', 'cache.hit']);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('cache-operation-logging/F04 异步 set 拒绝被观察但不改变业务结果', async () => {
|
|
311
|
+
let rejectWrite: ((reason?: unknown) => void) | undefined;
|
|
312
|
+
const writeError = new Error('async write failed');
|
|
313
|
+
const writePromise = new Promise<void>((_resolve, reject) => {
|
|
314
|
+
rejectWrite = reject;
|
|
315
|
+
});
|
|
316
|
+
const thenSpy = jest.spyOn(writePromise, 'then');
|
|
317
|
+
const provider = createProvider(undefined);
|
|
318
|
+
provider.set.mockReturnValue(writePromise);
|
|
319
|
+
CacheProviderRegistry.register('async-write-provider', provider.provider);
|
|
320
|
+
|
|
321
|
+
class UserService {
|
|
322
|
+
@Cache('async-write-users', { providerName: 'async-write-provider' })
|
|
323
|
+
getUser(): number {
|
|
324
|
+
return 10;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
await expect(new UserService().getUser()).resolves.toBe(10);
|
|
329
|
+
expect(provider.set).toHaveBeenCalledTimes(1);
|
|
330
|
+
expect(thenSpy).toHaveBeenCalledTimes(1);
|
|
331
|
+
expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched']);
|
|
332
|
+
expect(events(mockLogger.debug)).not.toContain('cache.write_completed');
|
|
333
|
+
|
|
334
|
+
if (!rejectWrite) {
|
|
335
|
+
throw new Error('Write rejecter was not initialized');
|
|
336
|
+
}
|
|
337
|
+
rejectWrite(writeError);
|
|
338
|
+
await Promise.resolve();
|
|
339
|
+
expect(loggedMetadata(mockLogger.error)).toContainEqual({
|
|
340
|
+
event: 'cache.operation_failed',
|
|
341
|
+
cacheName: 'async-write-users',
|
|
342
|
+
methodName: 'getUser',
|
|
343
|
+
providerName: 'async-write-provider',
|
|
344
|
+
operation: 'write',
|
|
345
|
+
error: writeError,
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('cache-operation-logging/F07 异常条目异步 set 拒绝被消费且保留原业务异常', async () => {
|
|
350
|
+
let rejectWrite: ((reason?: unknown) => void) | undefined;
|
|
351
|
+
const writeError = new Error('async error entry write failed');
|
|
352
|
+
const writePromise = new Promise<void>((_resolve, reject) => {
|
|
353
|
+
rejectWrite = reject;
|
|
354
|
+
});
|
|
355
|
+
const provider = createProvider(undefined);
|
|
356
|
+
provider.set.mockReturnValue(writePromise);
|
|
357
|
+
CacheProviderRegistry.register('async-error-write-provider', provider.provider);
|
|
358
|
+
const businessError = new Error('original business error');
|
|
359
|
+
|
|
360
|
+
class UserService {
|
|
361
|
+
@Cache('async-error-write-users', {
|
|
362
|
+
providerName: 'async-error-write-provider',
|
|
363
|
+
errorCache: { ttl: 5 },
|
|
364
|
+
})
|
|
365
|
+
getUser(): never {
|
|
366
|
+
throw businessError;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
await expect(new UserService().getUser()).rejects.toBe(businessError);
|
|
371
|
+
expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched']);
|
|
372
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
373
|
+
|
|
374
|
+
if (!rejectWrite) {
|
|
375
|
+
throw new Error('Write rejecter was not initialized');
|
|
376
|
+
}
|
|
377
|
+
rejectWrite(writeError);
|
|
378
|
+
await Promise.resolve();
|
|
379
|
+
expect(loggedMetadata(mockLogger.error)).toEqual([
|
|
380
|
+
{
|
|
381
|
+
event: 'cache.operation_failed',
|
|
382
|
+
cacheName: 'async-error-write-users',
|
|
383
|
+
methodName: 'getUser',
|
|
384
|
+
providerName: 'async-error-write-provider',
|
|
385
|
+
operation: 'write',
|
|
386
|
+
error: writeError,
|
|
387
|
+
},
|
|
388
|
+
]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('cache-operation-logging/F01 Provider 解析失败记录原始注册表异常并旁路执行业务', async () => {
|
|
392
|
+
const registryError = new Error('provider resolution failed');
|
|
393
|
+
jest.spyOn(CacheProviderRegistry, 'get').mockImplementation(() => {
|
|
394
|
+
throw registryError;
|
|
395
|
+
});
|
|
396
|
+
const businessResult = { id: 20 };
|
|
397
|
+
const businessMethod = jest.fn(() => businessResult);
|
|
398
|
+
|
|
399
|
+
class UserService {
|
|
400
|
+
@Cache('missing-provider-users', { providerName: 'missing-provider' })
|
|
401
|
+
getUser(): unknown {
|
|
402
|
+
return businessMethod();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await expect(new UserService().getUser()).resolves.toBe(businessResult);
|
|
407
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
408
|
+
expect(events(mockLogger.debug)).toEqual([]);
|
|
409
|
+
expect(loggedMetadata(mockLogger.error)).toEqual([
|
|
410
|
+
{
|
|
411
|
+
event: 'cache.operation_failed',
|
|
412
|
+
cacheName: 'missing-provider-users',
|
|
413
|
+
methodName: 'getUser',
|
|
414
|
+
providerName: 'missing-provider',
|
|
415
|
+
operation: 'provider_resolution',
|
|
416
|
+
error: registryError,
|
|
417
|
+
},
|
|
418
|
+
]);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('cache-operation-logging/F02 cache-operation-logging/M03 读取失败记录同一错误并旁路执行业务', async () => {
|
|
422
|
+
const readError = new Error('redis unavailable');
|
|
423
|
+
const provider = createProvider(Promise.reject(readError));
|
|
424
|
+
CacheProviderRegistry.register('unavailable-provider', provider.provider);
|
|
425
|
+
const businessResult = { id: 21 };
|
|
426
|
+
const businessMethod = jest.fn(() => businessResult);
|
|
427
|
+
|
|
428
|
+
class UserService {
|
|
429
|
+
@Cache('unavailable-users', { providerName: 'unavailable-provider' })
|
|
430
|
+
getUser(): unknown {
|
|
431
|
+
return businessMethod();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
await expect(new UserService().getUser()).resolves.toBe(businessResult);
|
|
436
|
+
expect(provider.get).toHaveBeenCalledTimes(1);
|
|
437
|
+
expect(provider.set).not.toHaveBeenCalled();
|
|
438
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
439
|
+
expect(events(mockLogger.debug)).not.toContain('cache.miss');
|
|
440
|
+
expect(loggedMetadata(mockLogger.error)).toEqual([
|
|
441
|
+
{
|
|
442
|
+
event: 'cache.operation_failed',
|
|
443
|
+
cacheName: 'unavailable-users',
|
|
444
|
+
methodName: 'getUser',
|
|
445
|
+
providerName: 'unavailable-provider',
|
|
446
|
+
operation: 'read',
|
|
447
|
+
error: readError,
|
|
448
|
+
},
|
|
449
|
+
]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('cache-operation-logging/M01 默认和显式 Provider 使用稳定标签', async () => {
|
|
453
|
+
const defaultProvider = createProvider({ value: 'default-value' });
|
|
454
|
+
const explicitProvider = createProvider({ value: 'explicit-value' });
|
|
455
|
+
CacheProviderRegistry.register('default-provider', defaultProvider.provider);
|
|
456
|
+
CacheProviderRegistry.register('explicit-provider', explicitProvider.provider);
|
|
457
|
+
CacheProviderRegistry.setDefault('default-provider');
|
|
458
|
+
|
|
459
|
+
class UserService {
|
|
460
|
+
@Cache('default-users')
|
|
461
|
+
getDefault(): string {
|
|
462
|
+
return 'business';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
@Cache('explicit-users', { providerName: 'explicit-provider' })
|
|
466
|
+
getExplicit(): string {
|
|
467
|
+
return 'business';
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const service = new UserService();
|
|
472
|
+
await expect(service.getDefault()).resolves.toBe('default-value');
|
|
473
|
+
await expect(service.getExplicit()).resolves.toBe('explicit-value');
|
|
474
|
+
expect(loggedMetadata(mockLogger.debug)).toEqual(
|
|
475
|
+
expect.arrayContaining([
|
|
476
|
+
expect.objectContaining({
|
|
477
|
+
event: 'cache.hit',
|
|
478
|
+
cacheName: 'default-users',
|
|
479
|
+
methodName: 'getDefault',
|
|
480
|
+
providerName: 'default',
|
|
481
|
+
}),
|
|
482
|
+
expect.objectContaining({
|
|
483
|
+
event: 'cache.hit',
|
|
484
|
+
cacheName: 'explicit-users',
|
|
485
|
+
methodName: 'getExplicit',
|
|
486
|
+
providerName: 'explicit-provider',
|
|
487
|
+
}),
|
|
488
|
+
])
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('cache-operation-logging/M02 参数、key、缓存值和返回值不进入日志元数据', async () => {
|
|
493
|
+
const secretTokens = ['13800138000', 'user@example.com', 'credential-token', '敏感值'];
|
|
494
|
+
const circularArgument: Record<string, unknown> = {
|
|
495
|
+
phone: secretTokens[0],
|
|
496
|
+
email: secretTokens[1],
|
|
497
|
+
};
|
|
498
|
+
circularArgument.self = circularArgument;
|
|
499
|
+
const circularValue: Record<string, unknown> = {
|
|
500
|
+
credential: secretTokens[2],
|
|
501
|
+
unicode: secretTokens[3],
|
|
502
|
+
empty: '',
|
|
503
|
+
};
|
|
504
|
+
circularValue.self = circularValue;
|
|
505
|
+
const valueToJson = jest.fn(() => {
|
|
506
|
+
throw new Error('cache value must not be serialized for logging');
|
|
507
|
+
});
|
|
508
|
+
Object.defineProperty(circularValue, 'toJSON', { value: valueToJson });
|
|
509
|
+
|
|
510
|
+
const provider = createProvider(undefined);
|
|
511
|
+
provider.get.mockImplementation((key) => (key.startsWith('sensitive-hit') ? { value: circularValue } : undefined));
|
|
512
|
+
CacheProviderRegistry.register('sensitive-provider', provider.provider);
|
|
513
|
+
|
|
514
|
+
class UserService {
|
|
515
|
+
@Cache('sensitive-hit', {
|
|
516
|
+
providerName: 'sensitive-provider',
|
|
517
|
+
key: () => 'credential-token:physical-key',
|
|
518
|
+
})
|
|
519
|
+
getCached(_input: unknown): unknown {
|
|
520
|
+
return 'business';
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
@Cache('sensitive-miss', {
|
|
524
|
+
providerName: 'sensitive-provider',
|
|
525
|
+
key: () => 'user@example.com:physical-key',
|
|
526
|
+
})
|
|
527
|
+
getFresh(_input: unknown): unknown {
|
|
528
|
+
return circularValue;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const service = new UserService();
|
|
533
|
+
await expect(service.getCached(circularArgument)).resolves.toBe(circularValue);
|
|
534
|
+
await expect(service.getFresh(circularArgument)).resolves.toBe(circularValue);
|
|
535
|
+
|
|
536
|
+
const metadata = loggedMetadata(mockLogger.debug);
|
|
537
|
+
const serializedMetadata = JSON.stringify(metadata);
|
|
538
|
+
for (const token of secretTokens) {
|
|
539
|
+
expect(serializedMetadata).not.toContain(token);
|
|
540
|
+
}
|
|
541
|
+
expect(metadata.every((item) => !('key' in item) && !('value' in item) && !('args' in item))).toBe(true);
|
|
542
|
+
expect(valueToJson).not.toHaveBeenCalled();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('cache-operation-logging/L03 Logger debug 失败不影响 cache hit', async () => {
|
|
546
|
+
const cachedValue = { id: 11 };
|
|
547
|
+
const provider = createProvider({ value: cachedValue });
|
|
548
|
+
CacheProviderRegistry.register('logger-hit-provider', provider.provider);
|
|
549
|
+
mockLogger.debug.mockImplementationOnce(() => {
|
|
550
|
+
throw new Error('logger debug failed');
|
|
551
|
+
});
|
|
552
|
+
const businessMethod = jest.fn();
|
|
553
|
+
|
|
554
|
+
class UserService {
|
|
555
|
+
@Cache('logger-hit-users', { providerName: 'logger-hit-provider' })
|
|
556
|
+
getUser(): unknown {
|
|
557
|
+
return businessMethod();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
await expect(new UserService().getUser()).resolves.toBe(cachedValue);
|
|
562
|
+
expect(businessMethod).not.toHaveBeenCalled();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('cache-operation-logging/L04 Logger error 失败不影响读取失败后的业务旁路', async () => {
|
|
566
|
+
const readError = new Error('provider read failed');
|
|
567
|
+
const provider = createProvider(Promise.reject(readError));
|
|
568
|
+
CacheProviderRegistry.register('logger-error-provider', provider.provider);
|
|
569
|
+
mockLogger.error.mockImplementationOnce(() => {
|
|
570
|
+
throw new Error('logger error failed');
|
|
571
|
+
});
|
|
572
|
+
const businessMethod = jest.fn(() => 'business-result');
|
|
573
|
+
|
|
574
|
+
class UserService {
|
|
575
|
+
@Cache('logger-error-users', { providerName: 'logger-error-provider' })
|
|
576
|
+
getUser(): unknown {
|
|
577
|
+
return businessMethod();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
await expect(new UserService().getUser()).resolves.toBe('business-result');
|
|
582
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
583
|
+
expect(events(mockLogger.debug)).not.toContain('cache.miss');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('cache-operation-logging/L04 Provider 与 Logger 同时失败时保留原业务异常', async () => {
|
|
587
|
+
const providerError = new Error('provider read failed');
|
|
588
|
+
const provider = createProvider(Promise.reject(providerError));
|
|
589
|
+
CacheProviderRegistry.register('logger-business-error-provider', provider.provider);
|
|
590
|
+
mockLogger.error.mockImplementationOnce(() => {
|
|
591
|
+
throw new Error('logger error failed');
|
|
592
|
+
});
|
|
593
|
+
const businessError = new Error('original business failure');
|
|
594
|
+
const shouldCache = jest.fn((_error: unknown): boolean => true);
|
|
595
|
+
|
|
596
|
+
class UserService {
|
|
597
|
+
@Cache('logger-business-error-users', {
|
|
598
|
+
providerName: 'logger-business-error-provider',
|
|
599
|
+
errorCache: { ttl: 5, shouldCache },
|
|
600
|
+
})
|
|
601
|
+
getUser(): never {
|
|
602
|
+
throw businessError;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
await expect(new UserService().getUser()).rejects.toBe(businessError);
|
|
607
|
+
expect(shouldCache).not.toHaveBeenCalled();
|
|
608
|
+
expect(provider.set).not.toHaveBeenCalled();
|
|
609
|
+
expect(events(mockLogger.debug)).not.toContain('cache.miss');
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('正常结果同步 set 失败只记录一次 write operation 且保留业务结果', async () => {
|
|
613
|
+
const writeError = new Error('synchronous write failed');
|
|
614
|
+
const provider = createProvider(undefined);
|
|
615
|
+
const shouldCache = jest.fn((_error: unknown): boolean => true);
|
|
616
|
+
provider.set.mockImplementationOnce(() => {
|
|
617
|
+
throw writeError;
|
|
618
|
+
});
|
|
619
|
+
CacheProviderRegistry.register('sync-write-provider', provider.provider);
|
|
620
|
+
|
|
621
|
+
class UserService {
|
|
622
|
+
@Cache('sync-write-users', {
|
|
623
|
+
providerName: 'sync-write-provider',
|
|
624
|
+
errorCache: { ttl: 5, shouldCache },
|
|
625
|
+
})
|
|
626
|
+
getUser(): number {
|
|
627
|
+
return 12;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
await expect(new UserService().getUser()).resolves.toBe(12);
|
|
632
|
+
expect(provider.set).toHaveBeenCalledTimes(1);
|
|
633
|
+
expect(shouldCache).not.toHaveBeenCalled();
|
|
634
|
+
expect(loggedMetadata(mockLogger.error)).toContainEqual({
|
|
635
|
+
event: 'cache.operation_failed',
|
|
636
|
+
cacheName: 'sync-write-users',
|
|
637
|
+
methodName: 'getUser',
|
|
638
|
+
providerName: 'sync-write-provider',
|
|
639
|
+
operation: 'write',
|
|
640
|
+
error: writeError,
|
|
641
|
+
});
|
|
642
|
+
expect(events(mockLogger.debug)).not.toContain('cache.write_dispatched');
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it('cache-operation-logging/按配置跳过异常缓存:disabled 记录 debug 且不写入', async () => {
|
|
646
|
+
const provider = createProvider(undefined);
|
|
647
|
+
CacheProviderRegistry.register('disabled-error-cache', provider.provider);
|
|
648
|
+
const businessError = new Error('transient business failure');
|
|
649
|
+
|
|
650
|
+
class UserService {
|
|
651
|
+
@Cache('disabled-error-users', { providerName: 'disabled-error-cache' })
|
|
652
|
+
async getUser(): Promise<never> {
|
|
653
|
+
throw businessError;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
await expect(new UserService().getUser()).rejects.toBe(businessError);
|
|
658
|
+
expect(loggedMetadata(mockLogger.debug)).toEqual([
|
|
659
|
+
{
|
|
660
|
+
event: 'cache.miss',
|
|
661
|
+
cacheName: 'disabled-error-users',
|
|
662
|
+
methodName: 'getUser',
|
|
663
|
+
providerName: 'disabled-error-cache',
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
event: 'cache.error_cache_skipped',
|
|
667
|
+
cacheName: 'disabled-error-users',
|
|
668
|
+
methodName: 'getUser',
|
|
669
|
+
providerName: 'disabled-error-cache',
|
|
670
|
+
reason: 'disabled',
|
|
671
|
+
},
|
|
672
|
+
]);
|
|
673
|
+
expect(provider.set).not.toHaveBeenCalled();
|
|
674
|
+
expect(mockLogger.warn).not.toHaveBeenCalled();
|
|
675
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('cache-operation-logging/按配置跳过异常缓存:predicate_rejected 不记录 write', async () => {
|
|
679
|
+
const provider = createProvider(undefined);
|
|
680
|
+
CacheProviderRegistry.register('predicate-rejected', provider.provider);
|
|
681
|
+
const businessError = new Error('rate limited');
|
|
682
|
+
const shouldCache = jest.fn((_error: unknown): boolean => false);
|
|
683
|
+
|
|
684
|
+
class UserService {
|
|
685
|
+
@Cache('predicate-rejected-users', {
|
|
686
|
+
providerName: 'predicate-rejected',
|
|
687
|
+
errorCache: { ttl: 5, shouldCache },
|
|
688
|
+
})
|
|
689
|
+
async getUser(): Promise<never> {
|
|
690
|
+
throw businessError;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
await expect(new UserService().getUser()).rejects.toBe(businessError);
|
|
695
|
+
expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.error_cache_skipped']);
|
|
696
|
+
expect(loggedMetadata(mockLogger.debug)[1]).toEqual({
|
|
697
|
+
event: 'cache.error_cache_skipped',
|
|
698
|
+
cacheName: 'predicate-rejected-users',
|
|
699
|
+
methodName: 'getUser',
|
|
700
|
+
providerName: 'predicate-rejected',
|
|
701
|
+
reason: 'predicate_rejected',
|
|
702
|
+
});
|
|
703
|
+
expect(shouldCache).toHaveBeenCalledTimes(1);
|
|
704
|
+
expect(provider.set).not.toHaveBeenCalled();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it('cache-operation-logging/C07 legacy_entry 旁路后记录 miss 并回填正常值', async () => {
|
|
708
|
+
const provider = createProvider({ error: { message: 'legacy payload' } });
|
|
709
|
+
CacheProviderRegistry.register('legacy-entry', provider.provider);
|
|
710
|
+
const businessMethod = jest.fn(() => 'fresh');
|
|
711
|
+
|
|
712
|
+
class UserService {
|
|
713
|
+
@Cache('legacy-entry-users', { providerName: 'legacy-entry', errorCache: { ttl: 5 } })
|
|
714
|
+
async getUser(): Promise<string> {
|
|
715
|
+
return businessMethod();
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
await expect(new UserService().getUser()).resolves.toBe('fresh');
|
|
720
|
+
expect(events(mockLogger.debug)).toEqual(['cache.error_cache_skipped', 'cache.miss', 'cache.write_dispatched']);
|
|
721
|
+
expect(loggedMetadata(mockLogger.debug)[0]).toEqual({
|
|
722
|
+
event: 'cache.error_cache_skipped',
|
|
723
|
+
cacheName: 'legacy-entry-users',
|
|
724
|
+
methodName: 'getUser',
|
|
725
|
+
providerName: 'legacy-entry',
|
|
726
|
+
reason: 'legacy_entry',
|
|
727
|
+
});
|
|
728
|
+
expect(events(mockLogger.debug)).not.toContain('cache.hit');
|
|
729
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it('cache-operation-logging/C07 disabled_entry 旁路后记录 miss 且不解码', async () => {
|
|
733
|
+
const provider = createProvider(currentErrorEntry({ type: 'error', name: 'Error', message: 'cached' }));
|
|
734
|
+
CacheProviderRegistry.register('disabled-entry', provider.provider);
|
|
735
|
+
const businessMethod = jest.fn(() => 'fresh');
|
|
736
|
+
|
|
737
|
+
class UserService {
|
|
738
|
+
@Cache('disabled-entry-users', { providerName: 'disabled-entry' })
|
|
739
|
+
async getUser(): Promise<string> {
|
|
740
|
+
return businessMethod();
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
await expect(new UserService().getUser()).resolves.toBe('fresh');
|
|
745
|
+
expect(events(mockLogger.debug)).toEqual(['cache.error_cache_skipped', 'cache.miss', 'cache.write_dispatched']);
|
|
746
|
+
expect(loggedMetadata(mockLogger.debug)[0]).toEqual({
|
|
747
|
+
event: 'cache.error_cache_skipped',
|
|
748
|
+
cacheName: 'disabled-entry-users',
|
|
749
|
+
methodName: 'getUser',
|
|
750
|
+
providerName: 'disabled-entry',
|
|
751
|
+
reason: 'disabled_entry',
|
|
752
|
+
});
|
|
753
|
+
expect(events(mockLogger.debug)).not.toContain('cache.hit');
|
|
754
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it.each([
|
|
758
|
+
[
|
|
759
|
+
'predicate',
|
|
760
|
+
(): boolean => {
|
|
761
|
+
throw new Error('predicate failed');
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
encode: jest.fn((error: unknown): unknown => error),
|
|
765
|
+
decode: jest.fn((payload: unknown): unknown => payload),
|
|
766
|
+
},
|
|
767
|
+
],
|
|
768
|
+
[
|
|
769
|
+
'encode',
|
|
770
|
+
(): boolean => true,
|
|
771
|
+
{
|
|
772
|
+
encode: jest.fn((): never => {
|
|
773
|
+
throw new Error('encode failed');
|
|
774
|
+
}),
|
|
775
|
+
decode: jest.fn((payload: unknown): unknown => payload),
|
|
776
|
+
},
|
|
777
|
+
],
|
|
778
|
+
] as const)('cache-operation-logging/C08 %s failure 只记录稳定 phase', async (phase, shouldCache, codec) => {
|
|
779
|
+
const provider = createProvider(undefined);
|
|
780
|
+
const providerName = `${phase}-failure`;
|
|
781
|
+
CacheProviderRegistry.register(providerName, provider.provider);
|
|
782
|
+
const businessError = new Error('original business failure');
|
|
783
|
+
|
|
784
|
+
class UserService {
|
|
785
|
+
@Cache(`${phase}-failure-users`, {
|
|
786
|
+
providerName,
|
|
787
|
+
errorCache: { ttl: 5, shouldCache, codec },
|
|
788
|
+
})
|
|
789
|
+
async getUser(): Promise<never> {
|
|
790
|
+
throw businessError;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
await expect(new UserService().getUser()).rejects.toBe(businessError);
|
|
795
|
+
expect(events(mockLogger.debug)).toEqual(['cache.miss']);
|
|
796
|
+
expect(loggedMetadata(mockLogger.warn)).toEqual([
|
|
797
|
+
{
|
|
798
|
+
event: 'cache.error_cache_failed',
|
|
799
|
+
cacheName: `${phase}-failure-users`,
|
|
800
|
+
methodName: 'getUser',
|
|
801
|
+
providerName,
|
|
802
|
+
phase,
|
|
803
|
+
},
|
|
804
|
+
]);
|
|
805
|
+
expect(mockLogger.debug.mock.invocationCallOrder[0]).toBeLessThan(mockLogger.warn.mock.invocationCallOrder[0]!);
|
|
806
|
+
expect(provider.set).not.toHaveBeenCalled();
|
|
807
|
+
expect(mockLogger.error).not.toHaveBeenCalled();
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('cache-operation-logging/C08 decode failure 先记录 warn,再按 miss 回填正常值', async () => {
|
|
811
|
+
const provider = createProvider(currentErrorEntry({ code: 'OTHER_CODEC' }));
|
|
812
|
+
CacheProviderRegistry.register('decode-failure', provider.provider);
|
|
813
|
+
const decode = jest.fn((): never => {
|
|
814
|
+
throw new Error('decode failed');
|
|
815
|
+
});
|
|
816
|
+
const businessMethod = jest.fn(() => 'fresh');
|
|
817
|
+
|
|
818
|
+
class UserService {
|
|
819
|
+
@Cache('decode-failure-users', {
|
|
820
|
+
providerName: 'decode-failure',
|
|
821
|
+
errorCache: { ttl: 5, codec: { encode: (error: unknown): unknown => error, decode } },
|
|
822
|
+
})
|
|
823
|
+
async getUser(): Promise<string> {
|
|
824
|
+
return businessMethod();
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
await expect(new UserService().getUser()).resolves.toBe('fresh');
|
|
829
|
+
expect(loggedMetadata(mockLogger.warn)).toEqual([
|
|
830
|
+
{
|
|
831
|
+
event: 'cache.error_cache_failed',
|
|
832
|
+
cacheName: 'decode-failure-users',
|
|
833
|
+
methodName: 'getUser',
|
|
834
|
+
providerName: 'decode-failure',
|
|
835
|
+
phase: 'decode',
|
|
836
|
+
},
|
|
837
|
+
]);
|
|
838
|
+
expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched']);
|
|
839
|
+
expect(events(mockLogger.debug)).not.toContain('cache.hit');
|
|
840
|
+
expect(mockLogger.warn.mock.invocationCallOrder[0]).toBeLessThan(mockLogger.debug.mock.invocationCallOrder[0]!);
|
|
841
|
+
expect(decode).toHaveBeenCalledTimes(1);
|
|
842
|
+
expect(businessMethod).toHaveBeenCalledTimes(1);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('cache-operation-logging/M02 M05 策略失败日志不包含业务错误、codec 错误或循环 payload', async () => {
|
|
846
|
+
const provider = createProvider(undefined);
|
|
847
|
+
CacheProviderRegistry.register('sensitive-error-policy', provider.provider);
|
|
848
|
+
const businessError = new Error('phone=13800138000 credential=business-token');
|
|
849
|
+
const codecError = new Error('credential=codec-token');
|
|
850
|
+
const cyclicPayload: Record<string, unknown> = { secret: 'payload-token' };
|
|
851
|
+
cyclicPayload.self = cyclicPayload;
|
|
852
|
+
const cyclicEncode = jest.fn((): unknown => cyclicPayload);
|
|
853
|
+
const failedEncode = jest.fn((): never => {
|
|
854
|
+
throw codecError;
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
class UserService {
|
|
858
|
+
@Cache('sensitive-cyclic-users', {
|
|
859
|
+
providerName: 'sensitive-error-policy',
|
|
860
|
+
errorCache: { ttl: 5, codec: { encode: cyclicEncode, decode: (payload: unknown): unknown => payload } },
|
|
861
|
+
})
|
|
862
|
+
async getCyclicUser(): Promise<never> {
|
|
863
|
+
throw businessError;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
@Cache('sensitive-codec-users', {
|
|
867
|
+
providerName: 'sensitive-error-policy',
|
|
868
|
+
errorCache: { ttl: 5, codec: { encode: failedEncode, decode: (payload: unknown): unknown => payload } },
|
|
869
|
+
})
|
|
870
|
+
async getCodecFailure(): Promise<never> {
|
|
871
|
+
throw businessError;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const service = new UserService();
|
|
876
|
+
await expect(service.getCyclicUser()).rejects.toBe(businessError);
|
|
877
|
+
await expect(service.getCodecFailure()).rejects.toBe(businessError);
|
|
878
|
+
const metadata = [...loggedMetadata(mockLogger.debug), ...loggedMetadata(mockLogger.warn)];
|
|
879
|
+
const serialized = JSON.stringify(metadata);
|
|
880
|
+
for (const secret of ['13800138000', 'business-token', 'codec-token', 'payload-token']) {
|
|
881
|
+
expect(serialized).not.toContain(secret);
|
|
882
|
+
}
|
|
883
|
+
expect(loggedMetadata(mockLogger.warn)).toEqual([
|
|
884
|
+
{
|
|
885
|
+
event: 'cache.error_cache_failed',
|
|
886
|
+
cacheName: 'sensitive-cyclic-users',
|
|
887
|
+
methodName: 'getCyclicUser',
|
|
888
|
+
providerName: 'sensitive-error-policy',
|
|
889
|
+
phase: 'encode',
|
|
890
|
+
},
|
|
891
|
+
{
|
|
892
|
+
event: 'cache.error_cache_failed',
|
|
893
|
+
cacheName: 'sensitive-codec-users',
|
|
894
|
+
methodName: 'getCodecFailure',
|
|
895
|
+
providerName: 'sensitive-error-policy',
|
|
896
|
+
phase: 'encode',
|
|
897
|
+
},
|
|
898
|
+
]);
|
|
899
|
+
expect(cyclicEncode).toHaveBeenCalledTimes(1);
|
|
900
|
+
expect(failedEncode).toHaveBeenCalledTimes(1);
|
|
901
|
+
expect(provider.set).not.toHaveBeenCalled();
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it('Logger warn 写入失败不改变策略旁路或原业务异常', async () => {
|
|
905
|
+
const provider = createProvider(undefined);
|
|
906
|
+
CacheProviderRegistry.register('failed-logger-policy', provider.provider);
|
|
907
|
+
const businessError = new Error('business error');
|
|
908
|
+
mockLogger.warn.mockImplementation(() => {
|
|
909
|
+
throw new Error('logger warn failed');
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
class UserService {
|
|
913
|
+
@Cache('failed-logger-users', {
|
|
914
|
+
providerName: 'failed-logger-policy',
|
|
915
|
+
errorCache: {
|
|
916
|
+
ttl: 5,
|
|
917
|
+
shouldCache: (): never => {
|
|
918
|
+
throw new Error('predicate failed');
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
})
|
|
922
|
+
async getUser(): Promise<never> {
|
|
923
|
+
throw businessError;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
await expect(new UserService().getUser()).rejects.toBe(businessError);
|
|
928
|
+
expect(provider.set).not.toHaveBeenCalled();
|
|
929
|
+
});
|