@jintianxiayu/cache-decorator 0.1.2 → 1.0.0

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +270 -133
  3. package/dist/adapters/ioredis-cache-client.d.ts +9 -0
  4. package/dist/adapters/ioredis-cache-client.d.ts.map +1 -0
  5. package/dist/adapters/ioredis-cache-client.js +73 -0
  6. package/dist/adapters/ioredis-cache-client.js.map +1 -0
  7. package/dist/adapters/node-redis-cache-client.d.ts +10 -0
  8. package/dist/adapters/node-redis-cache-client.d.ts.map +1 -0
  9. package/dist/adapters/node-redis-cache-client.js +81 -0
  10. package/dist/adapters/node-redis-cache-client.js.map +1 -0
  11. package/dist/adapters/redis-key-prefix.d.ts +22 -0
  12. package/dist/adapters/redis-key-prefix.d.ts.map +1 -0
  13. package/dist/adapters/redis-key-prefix.js +41 -0
  14. package/dist/adapters/redis-key-prefix.js.map +1 -0
  15. package/dist/core/cache-logger.d.ts +28 -0
  16. package/dist/core/cache-logger.d.ts.map +1 -0
  17. package/dist/core/cache-logger.js +70 -0
  18. package/dist/core/cache-logger.js.map +1 -0
  19. package/dist/core/cache-provider-registry.d.ts.map +1 -1
  20. package/dist/core/cache-provider-registry.js.map +1 -1
  21. package/dist/core/cache-provider.d.ts.map +1 -1
  22. package/dist/core/key-builder.d.ts.map +1 -1
  23. package/dist/core/key-builder.js +3 -2
  24. package/dist/core/key-builder.js.map +1 -1
  25. package/dist/core/native-cache.d.ts.map +1 -1
  26. package/dist/core/native-cache.js +2 -1
  27. package/dist/core/native-cache.js.map +1 -1
  28. package/dist/core/pending-cache.d.ts.map +1 -1
  29. package/dist/core/pending-cache.js +6 -3
  30. package/dist/core/pending-cache.js.map +1 -1
  31. package/dist/core/redis-cache-client.d.ts +156 -0
  32. package/dist/core/redis-cache-client.d.ts.map +1 -0
  33. package/dist/core/redis-cache-client.js +3 -0
  34. package/dist/core/redis-cache-client.js.map +1 -0
  35. package/dist/core/redis-cache.d.ts +39 -7
  36. package/dist/core/redis-cache.d.ts.map +1 -1
  37. package/dist/core/redis-cache.js +63 -22
  38. package/dist/core/redis-cache.js.map +1 -1
  39. package/dist/decorators/cache-evict.d.ts +1 -1
  40. package/dist/decorators/cache-evict.d.ts.map +1 -1
  41. package/dist/decorators/cache-evict.js +68 -14
  42. package/dist/decorators/cache-evict.js.map +1 -1
  43. package/dist/decorators/cache.d.ts +1 -1
  44. package/dist/decorators/cache.d.ts.map +1 -1
  45. package/dist/decorators/cache.js +85 -23
  46. package/dist/decorators/cache.js.map +1 -1
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +6 -0
  50. package/dist/index.js.map +1 -1
  51. package/jest.config.js +11 -14
  52. package/package.json +26 -29
  53. package/src/adapters/ioredis-cache-client.ts +89 -0
  54. package/src/adapters/node-redis-cache-client.ts +95 -0
  55. package/src/adapters/redis-key-prefix.ts +38 -0
  56. package/src/core/cache-logger.ts +105 -0
  57. package/src/core/cache-provider-registry.ts +39 -39
  58. package/src/core/cache-provider.ts +28 -28
  59. package/src/core/key-builder.ts +33 -31
  60. package/src/core/native-cache.ts +55 -53
  61. package/src/core/pending-cache.ts +29 -26
  62. package/src/core/redis-cache-client.ts +160 -0
  63. package/src/core/redis-cache.ts +104 -62
  64. package/src/decorators/cache-evict.ts +129 -73
  65. package/src/decorators/cache.ts +203 -129
  66. package/src/index.ts +11 -9
  67. package/test/cache-evict-logging.test.ts +362 -0
  68. package/test/cache-evict.test.ts +165 -165
  69. package/test/cache-logger.integration.test.ts +129 -0
  70. package/test/cache-logger.test.ts +153 -0
  71. package/test/cache-logging.test.ts +544 -0
  72. package/test/cache-provider-registry.test.ts +24 -24
  73. package/test/cache.test.ts +255 -256
  74. package/test/fixtures/cache-logger-child.mjs +108 -0
  75. package/test/helpers/legacy-redis-cache.ts +22 -0
  76. package/test/helpers/package-consumer.ts +231 -0
  77. package/test/helpers/redis-fixture.ts +142 -0
  78. package/test/ioredis-cache-client.test.ts +143 -0
  79. package/test/key-builder.test.ts +28 -28
  80. package/test/native-cache.test.ts +77 -71
  81. package/test/node-redis-cache-client.test.ts +149 -0
  82. package/test/pending-cache.test.ts +69 -56
  83. package/test/redis-cache-client-lifecycle.test.ts +112 -0
  84. package/test/redis-cache-client-types.test.ts +184 -0
  85. package/test/redis-cache-client.integration.test.ts +327 -0
  86. package/test/redis-cache-decorator.test.ts +201 -0
  87. package/test/redis-cache-package.integration.test.ts +328 -0
  88. package/test/redis-cache-provider.test.ts +269 -0
  89. package/test/type-contract/contract.ts +64 -0
  90. package/test/type-contract/tsconfig.json +12 -0
  91. package/tsconfig.json +8 -8
@@ -0,0 +1,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 operation?: string;
27
+ readonly error?: unknown;
28
+ }
29
+
30
+ interface Deferred<Value> {
31
+ readonly promise: Promise<Value>;
32
+ readonly resolve: (value: Value) => void;
33
+ }
34
+
35
+ const mockLogger: jest.Mocked<LoggerInterface> = {
36
+ debug: jest.fn(),
37
+ info: jest.fn(),
38
+ warn: jest.fn(),
39
+ error: jest.fn(),
40
+ };
41
+ const mockGetLogger = jest.mocked(LoggerFactory.getLogger);
42
+
43
+ /** 为并发请求测试创建由用例显式完成的 Promise。 */
44
+ function createDeferred<Value>(): Deferred<Value> {
45
+ let resolvePromise: ((value: Value) => void) | undefined;
46
+ const promise = new Promise<Value>((resolve) => {
47
+ resolvePromise = resolve;
48
+ });
49
+ if (!resolvePromise) {
50
+ throw new Error('Deferred resolver was not initialized');
51
+ }
52
+ return { promise, resolve: resolvePromise };
53
+ }
54
+
55
+ /** 创建仅记录 decorator 实际调用次数和参数的可插拔 Provider。 */
56
+ function createProvider(getResult: unknown): ProviderHarness {
57
+ const get = jest.fn<unknown, [string]>(() => getResult);
58
+ const set = jest.fn<unknown, [string, unknown, number?]>();
59
+ const provider: CacheProvider = {
60
+ get: <Value>(key: string): Value | undefined | Promise<Value | undefined> =>
61
+ get(key) as Value | undefined | Promise<Value | undefined>,
62
+ set: <Value>(key: string, value: Value, ttl?: number): void | Promise<void> =>
63
+ set(key, value, ttl) as void | Promise<void>,
64
+ delete: (_key: string): void => {
65
+ return;
66
+ },
67
+ clear: (): void => {
68
+ return;
69
+ },
70
+ deleteByPattern: (_pattern: string): void => {
71
+ return;
72
+ },
73
+ };
74
+ return { provider, get, set };
75
+ }
76
+
77
+ function loggedMetadata(method: jest.Mock): CacheLogMetadata[] {
78
+ return method.mock.calls.map((call) => call[1] as CacheLogMetadata);
79
+ }
80
+
81
+ function events(method: jest.Mock): string[] {
82
+ return loggedMetadata(method).map(({ event }) => event);
83
+ }
84
+
85
+ beforeEach(() => {
86
+ jest.clearAllMocks();
87
+ mockGetLogger.mockReturnValue(mockLogger);
88
+ CacheProviderRegistry.clear();
89
+ });
90
+
91
+ afterEach(() => {
92
+ jest.restoreAllMocks();
93
+ CacheProviderRegistry.clear();
94
+ });
95
+
96
+ it('cache-operation-logging/C01 cache-operation-logging/A02 并发调用复用同一 pending Promise', async () => {
97
+ const provider = createProvider(undefined);
98
+ CacheProviderRegistry.register('pending-provider', provider.provider);
99
+ CacheProviderRegistry.setDefault('pending-provider');
100
+ const result = createDeferred<number>();
101
+ const businessMethod = jest.fn(() => result.promise);
102
+
103
+ class UserService {
104
+ @Cache('pending-users')
105
+ getUser(id: number): Promise<number> {
106
+ return businessMethod(id);
107
+ }
108
+ }
109
+
110
+ const service = new UserService();
111
+ const first = service.getUser(7);
112
+ const second = service.getUser(7);
113
+
114
+ expect(second).toBe(first);
115
+ expect(provider.get).toHaveBeenCalledTimes(1);
116
+ await Promise.resolve();
117
+ expect(businessMethod).toHaveBeenCalledTimes(1);
118
+ result.resolve(42);
119
+ await expect(Promise.all([first, second])).resolves.toEqual([42, 42]);
120
+ expect(events(mockLogger.debug).filter((event) => event === 'cache.pending_hit')).toHaveLength(1);
121
+ expect(events(mockLogger.debug).filter((event) => event === 'cache.miss')).toHaveLength(1);
122
+ expect(events(mockLogger.debug)).not.toContain('cache.hit');
123
+ });
124
+
125
+ it('cache-operation-logging/C02 命中 value entry 时记录 hit 并跳过业务方法', async () => {
126
+ const cachedValue = { id: 7, source: 'cache' };
127
+ const provider = createProvider({ value: cachedValue });
128
+ CacheProviderRegistry.register('value-provider', provider.provider);
129
+ const businessMethod = jest.fn();
130
+
131
+ class UserService {
132
+ @Cache('value-users', { providerName: 'value-provider' })
133
+ getUser(): unknown {
134
+ return businessMethod();
135
+ }
136
+ }
137
+
138
+ await expect(new UserService().getUser()).resolves.toBe(cachedValue);
139
+ expect(businessMethod).not.toHaveBeenCalled();
140
+ expect(loggedMetadata(mockLogger.debug)).toContainEqual({
141
+ event: 'cache.hit',
142
+ cacheName: 'value-users',
143
+ methodName: 'getUser',
144
+ providerName: 'value-provider',
145
+ entryType: 'value',
146
+ });
147
+ });
148
+
149
+ it('cache-operation-logging/C03 命中 error entry 时记录 hit 并抛出同一异常', async () => {
150
+ const cachedError = new Error('cached business failure');
151
+ const provider = createProvider({ error: cachedError });
152
+ CacheProviderRegistry.register('error-entry-provider', provider.provider);
153
+ const businessMethod = jest.fn();
154
+
155
+ class UserService {
156
+ @Cache('error-users', { providerName: 'error-entry-provider' })
157
+ getUser(): unknown {
158
+ return businessMethod();
159
+ }
160
+ }
161
+
162
+ await expect(new UserService().getUser()).rejects.toBe(cachedError);
163
+ expect(businessMethod).not.toHaveBeenCalled();
164
+ expect(loggedMetadata(mockLogger.debug)).toContainEqual({
165
+ event: 'cache.hit',
166
+ cacheName: 'error-users',
167
+ methodName: 'getUser',
168
+ providerName: 'error-entry-provider',
169
+ entryType: 'error',
170
+ });
171
+ expect(mockLogger.error).not.toHaveBeenCalled();
172
+ });
173
+
174
+ it('cache-operation-logging/K01 resolver 失败时记录无敏感数据的 fallback 并使用默认 key', async () => {
175
+ const secretArgument = { phone: '13800138000', token: 'credential' };
176
+ const provider = createProvider({ value: 'cached' });
177
+ CacheProviderRegistry.register('fallback-provider', provider.provider);
178
+ const resolver = jest.fn(() => {
179
+ throw new Error('resolver leaked secret');
180
+ });
181
+
182
+ class UserService {
183
+ @Cache('fallback-users', { providerName: 'fallback-provider', key: resolver })
184
+ getUser(_input: unknown): string {
185
+ return 'business';
186
+ }
187
+ }
188
+
189
+ await expect(new UserService().getUser(secretArgument)).resolves.toBe('cached');
190
+ expect(resolver).toHaveBeenCalledTimes(1);
191
+ expect(provider.get).toHaveBeenCalledWith(KeyBuilder.build('fallback-users', [secretArgument]));
192
+ const fallback = loggedMetadata(mockLogger.warn).find(({ event }) => event === 'cache.key_fallback');
193
+ expect(fallback).toEqual({
194
+ event: 'cache.key_fallback',
195
+ cacheName: 'fallback-users',
196
+ methodName: 'getUser',
197
+ providerName: 'fallback-provider',
198
+ reason: 'resolver_error',
199
+ });
200
+ expect(JSON.stringify(fallback)).not.toContain('13800138000');
201
+ expect(JSON.stringify(fallback)).not.toContain('credential');
202
+ expect(JSON.stringify(fallback)).not.toContain('resolver leaked secret');
203
+ });
204
+
205
+ it('cache-operation-logging/C04 miss 后回填 value entry 并记录 dispatched', async () => {
206
+ const provider = createProvider(undefined);
207
+ CacheProviderRegistry.register('write-value-provider', provider.provider);
208
+ const businessResult = { id: 8 };
209
+ const businessMethod = jest.fn(() => businessResult);
210
+
211
+ class UserService {
212
+ @Cache('write-value-users', { providerName: 'write-value-provider', ttl: 30 })
213
+ getUser(): { id: number } {
214
+ return businessMethod();
215
+ }
216
+ }
217
+
218
+ await expect(new UserService().getUser()).resolves.toBe(businessResult);
219
+ expect(provider.get).toHaveBeenCalledTimes(1);
220
+ expect(businessMethod).toHaveBeenCalledTimes(1);
221
+ expect(provider.set).toHaveBeenCalledWith(expect.any(String), { value: businessResult }, 30);
222
+ expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched']);
223
+ expect(loggedMetadata(mockLogger.debug)[1]).toEqual({
224
+ event: 'cache.write_dispatched',
225
+ cacheName: 'write-value-users',
226
+ methodName: 'getUser',
227
+ providerName: 'write-value-provider',
228
+ entryType: 'value',
229
+ });
230
+ });
231
+
232
+ it('cache-operation-logging/C05 业务异常回填 error entry 后抛出同一异常', async () => {
233
+ const provider = createProvider(undefined);
234
+ CacheProviderRegistry.register('write-error-provider', provider.provider);
235
+ const businessError = new Error('business failed');
236
+ const businessMethod = jest.fn(() => {
237
+ throw businessError;
238
+ });
239
+
240
+ class UserService {
241
+ @Cache('write-error-users', { providerName: 'write-error-provider' })
242
+ getUser(): never {
243
+ return businessMethod();
244
+ }
245
+ }
246
+
247
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
248
+ expect(provider.get).toHaveBeenCalledTimes(1);
249
+ expect(businessMethod).toHaveBeenCalledTimes(1);
250
+ expect(provider.set).toHaveBeenCalledWith(expect.any(String), { error: businessError }, undefined);
251
+ expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched']);
252
+ expect(loggedMetadata(mockLogger.debug)[1]).toEqual({
253
+ event: 'cache.write_dispatched',
254
+ cacheName: 'write-error-users',
255
+ methodName: 'getUser',
256
+ providerName: 'write-error-provider',
257
+ entryType: 'error',
258
+ });
259
+ expect(mockLogger.error).not.toHaveBeenCalled();
260
+ });
261
+
262
+ it('cache-operation-logging/C06 回填后的重复调用命中缓存且不重复执行业务', async () => {
263
+ let storedEntry: unknown;
264
+ const provider = createProvider(undefined);
265
+ provider.get.mockImplementation(() => storedEntry);
266
+ provider.set.mockImplementation((_key, entry) => {
267
+ storedEntry = entry;
268
+ });
269
+ CacheProviderRegistry.register('repeat-provider', provider.provider);
270
+ const businessResult = { id: 9 };
271
+ const businessMethod = jest.fn(() => businessResult);
272
+
273
+ class UserService {
274
+ @Cache('repeat-users', { providerName: 'repeat-provider' })
275
+ getUser(): { id: number } {
276
+ return businessMethod();
277
+ }
278
+ }
279
+
280
+ const service = new UserService();
281
+ await expect(service.getUser()).resolves.toBe(businessResult);
282
+ await expect(service.getUser()).resolves.toBe(businessResult);
283
+
284
+ expect(provider.get).toHaveBeenCalledTimes(2);
285
+ expect(provider.set).toHaveBeenCalledTimes(1);
286
+ expect(businessMethod).toHaveBeenCalledTimes(1);
287
+ expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched', 'cache.hit']);
288
+ });
289
+
290
+ it('cache-operation-logging/F04 异步 set 拒绝不会被等待或消费', async () => {
291
+ let rejectWrite: ((reason?: unknown) => void) | undefined;
292
+ const writeError = new Error('async write failed');
293
+ const writePromise = new Promise<void>((_resolve, reject) => {
294
+ rejectWrite = reject;
295
+ });
296
+ const observedWriteFailure = writePromise.catch((error: unknown) => error);
297
+ const thenSpy = jest.spyOn(writePromise, 'then');
298
+ const provider = createProvider(undefined);
299
+ provider.set.mockReturnValue(writePromise);
300
+ CacheProviderRegistry.register('async-write-provider', provider.provider);
301
+
302
+ class UserService {
303
+ @Cache('async-write-users', { providerName: 'async-write-provider' })
304
+ getUser(): number {
305
+ return 10;
306
+ }
307
+ }
308
+
309
+ await expect(new UserService().getUser()).resolves.toBe(10);
310
+ expect(provider.set).toHaveBeenCalledTimes(1);
311
+ expect(thenSpy).not.toHaveBeenCalled();
312
+ expect(events(mockLogger.debug)).toEqual(['cache.miss', 'cache.write_dispatched']);
313
+ expect(events(mockLogger.debug)).not.toContain('cache.write_completed');
314
+
315
+ if (!rejectWrite) {
316
+ throw new Error('Write rejecter was not initialized');
317
+ }
318
+ rejectWrite(writeError);
319
+ await expect(observedWriteFailure).resolves.toBe(writeError);
320
+ });
321
+
322
+ it('cache-operation-logging/F01 Provider 解析失败记录原始注册表异常且不执行业务', async () => {
323
+ const registryError = new Error('provider resolution failed');
324
+ jest.spyOn(CacheProviderRegistry, 'get').mockImplementation(() => {
325
+ throw registryError;
326
+ });
327
+ const businessMethod = jest.fn();
328
+
329
+ class UserService {
330
+ @Cache('missing-provider-users', { providerName: 'missing-provider' })
331
+ getUser(): unknown {
332
+ return businessMethod();
333
+ }
334
+ }
335
+
336
+ await expect(new UserService().getUser()).rejects.toBe(registryError);
337
+ expect(businessMethod).not.toHaveBeenCalled();
338
+ expect(events(mockLogger.debug)).toEqual([]);
339
+ expect(loggedMetadata(mockLogger.error)).toEqual([
340
+ {
341
+ event: 'cache.operation_failed',
342
+ cacheName: 'missing-provider-users',
343
+ methodName: 'getUser',
344
+ providerName: 'missing-provider',
345
+ operation: 'provider_resolution',
346
+ error: registryError,
347
+ },
348
+ ]);
349
+ });
350
+
351
+ it('cache-operation-logging/F02 cache-operation-logging/M03 读取失败记录同一错误且不转为 miss', async () => {
352
+ const readError = new Error('redis unavailable');
353
+ const provider = createProvider(Promise.reject(readError));
354
+ CacheProviderRegistry.register('unavailable-provider', provider.provider);
355
+ const businessMethod = jest.fn();
356
+
357
+ class UserService {
358
+ @Cache('unavailable-users', { providerName: 'unavailable-provider' })
359
+ getUser(): unknown {
360
+ return businessMethod();
361
+ }
362
+ }
363
+
364
+ await expect(new UserService().getUser()).rejects.toBe(readError);
365
+ expect(provider.get).toHaveBeenCalledTimes(1);
366
+ expect(provider.set).not.toHaveBeenCalled();
367
+ expect(businessMethod).not.toHaveBeenCalled();
368
+ expect(events(mockLogger.debug)).not.toContain('cache.miss');
369
+ expect(loggedMetadata(mockLogger.error)).toEqual([
370
+ {
371
+ event: 'cache.operation_failed',
372
+ cacheName: 'unavailable-users',
373
+ methodName: 'getUser',
374
+ providerName: 'unavailable-provider',
375
+ operation: 'read',
376
+ error: readError,
377
+ },
378
+ ]);
379
+ });
380
+
381
+ it('cache-operation-logging/M01 默认和显式 Provider 使用稳定标签', async () => {
382
+ const defaultProvider = createProvider({ value: 'default-value' });
383
+ const explicitProvider = createProvider({ value: 'explicit-value' });
384
+ CacheProviderRegistry.register('default-provider', defaultProvider.provider);
385
+ CacheProviderRegistry.register('explicit-provider', explicitProvider.provider);
386
+ CacheProviderRegistry.setDefault('default-provider');
387
+
388
+ class UserService {
389
+ @Cache('default-users')
390
+ getDefault(): string {
391
+ return 'business';
392
+ }
393
+
394
+ @Cache('explicit-users', { providerName: 'explicit-provider' })
395
+ getExplicit(): string {
396
+ return 'business';
397
+ }
398
+ }
399
+
400
+ const service = new UserService();
401
+ await expect(service.getDefault()).resolves.toBe('default-value');
402
+ await expect(service.getExplicit()).resolves.toBe('explicit-value');
403
+ expect(loggedMetadata(mockLogger.debug)).toEqual(
404
+ expect.arrayContaining([
405
+ expect.objectContaining({
406
+ event: 'cache.hit',
407
+ cacheName: 'default-users',
408
+ methodName: 'getDefault',
409
+ providerName: 'default',
410
+ }),
411
+ expect.objectContaining({
412
+ event: 'cache.hit',
413
+ cacheName: 'explicit-users',
414
+ methodName: 'getExplicit',
415
+ providerName: 'explicit-provider',
416
+ }),
417
+ ])
418
+ );
419
+ });
420
+
421
+ it('cache-operation-logging/M02 参数、key、缓存值和返回值不进入日志元数据', async () => {
422
+ const secretTokens = ['13800138000', 'user@example.com', 'credential-token', '敏感值'];
423
+ const circularArgument: Record<string, unknown> = {
424
+ phone: secretTokens[0],
425
+ email: secretTokens[1],
426
+ };
427
+ circularArgument.self = circularArgument;
428
+ const circularValue: Record<string, unknown> = {
429
+ credential: secretTokens[2],
430
+ unicode: secretTokens[3],
431
+ empty: '',
432
+ };
433
+ circularValue.self = circularValue;
434
+ const valueToJson = jest.fn(() => {
435
+ throw new Error('cache value must not be serialized for logging');
436
+ });
437
+ Object.defineProperty(circularValue, 'toJSON', { value: valueToJson });
438
+
439
+ const provider = createProvider(undefined);
440
+ provider.get.mockImplementation((key) => (key.startsWith('sensitive-hit') ? { value: circularValue } : undefined));
441
+ CacheProviderRegistry.register('sensitive-provider', provider.provider);
442
+
443
+ class UserService {
444
+ @Cache('sensitive-hit', {
445
+ providerName: 'sensitive-provider',
446
+ key: () => 'credential-token:physical-key',
447
+ })
448
+ getCached(_input: unknown): unknown {
449
+ return 'business';
450
+ }
451
+
452
+ @Cache('sensitive-miss', {
453
+ providerName: 'sensitive-provider',
454
+ key: () => 'user@example.com:physical-key',
455
+ })
456
+ getFresh(_input: unknown): unknown {
457
+ return circularValue;
458
+ }
459
+ }
460
+
461
+ const service = new UserService();
462
+ await expect(service.getCached(circularArgument)).resolves.toBe(circularValue);
463
+ await expect(service.getFresh(circularArgument)).resolves.toBe(circularValue);
464
+
465
+ const metadata = loggedMetadata(mockLogger.debug);
466
+ const serializedMetadata = JSON.stringify(metadata);
467
+ for (const token of secretTokens) {
468
+ expect(serializedMetadata).not.toContain(token);
469
+ }
470
+ expect(metadata.every((item) => !('key' in item) && !('value' in item) && !('args' in item))).toBe(true);
471
+ expect(valueToJson).not.toHaveBeenCalled();
472
+ });
473
+
474
+ it('cache-operation-logging/L03 Logger debug 失败不影响 cache hit', async () => {
475
+ const cachedValue = { id: 11 };
476
+ const provider = createProvider({ value: cachedValue });
477
+ CacheProviderRegistry.register('logger-hit-provider', provider.provider);
478
+ mockLogger.debug.mockImplementationOnce(() => {
479
+ throw new Error('logger debug failed');
480
+ });
481
+ const businessMethod = jest.fn();
482
+
483
+ class UserService {
484
+ @Cache('logger-hit-users', { providerName: 'logger-hit-provider' })
485
+ getUser(): unknown {
486
+ return businessMethod();
487
+ }
488
+ }
489
+
490
+ await expect(new UserService().getUser()).resolves.toBe(cachedValue);
491
+ expect(businessMethod).not.toHaveBeenCalled();
492
+ });
493
+
494
+ it('cache-operation-logging/L04 Logger error 失败不遮蔽原始读取异常', async () => {
495
+ const readError = new Error('provider read failed');
496
+ const provider = createProvider(Promise.reject(readError));
497
+ CacheProviderRegistry.register('logger-error-provider', provider.provider);
498
+ mockLogger.error.mockImplementationOnce(() => {
499
+ throw new Error('logger error failed');
500
+ });
501
+ const businessMethod = jest.fn();
502
+
503
+ class UserService {
504
+ @Cache('logger-error-users', { providerName: 'logger-error-provider' })
505
+ getUser(): unknown {
506
+ return businessMethod();
507
+ }
508
+ }
509
+
510
+ await expect(new UserService().getUser()).rejects.toBe(readError);
511
+ expect(businessMethod).not.toHaveBeenCalled();
512
+ expect(events(mockLogger.debug)).not.toContain('cache.miss');
513
+ });
514
+
515
+ it('同步 set 失败记录 write operation 并保持既有错误回填控制流', async () => {
516
+ const writeError = new Error('synchronous write failed');
517
+ const provider = createProvider(undefined);
518
+ provider.set.mockImplementationOnce(() => {
519
+ throw writeError;
520
+ });
521
+ CacheProviderRegistry.register('sync-write-provider', provider.provider);
522
+
523
+ class UserService {
524
+ @Cache('sync-write-users', { providerName: 'sync-write-provider' })
525
+ getUser(): number {
526
+ return 12;
527
+ }
528
+ }
529
+
530
+ await expect(new UserService().getUser()).rejects.toBe(writeError);
531
+ expect(provider.set).toHaveBeenCalledTimes(2);
532
+ expect(provider.set.mock.calls[1]?.[1]).toEqual({ error: writeError });
533
+ expect(loggedMetadata(mockLogger.error)).toContainEqual({
534
+ event: 'cache.operation_failed',
535
+ cacheName: 'sync-write-users',
536
+ methodName: 'getUser',
537
+ providerName: 'sync-write-provider',
538
+ operation: 'write',
539
+ error: writeError,
540
+ });
541
+ expect(loggedMetadata(mockLogger.debug)).toContainEqual(
542
+ expect.objectContaining({ event: 'cache.write_dispatched', entryType: 'error' })
543
+ );
544
+ });
@@ -2,35 +2,35 @@ import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
2
2
  import { MemoryCacheProvider } from '../src/core/native-cache';
3
3
 
4
4
  describe('CacheProviderRegistry', () => {
5
- let memoryProvider: MemoryCacheProvider;
5
+ let memoryProvider: MemoryCacheProvider;
6
6
 
7
- beforeEach(() => {
8
- memoryProvider = new MemoryCacheProvider();
9
- CacheProviderRegistry.register('memory', memoryProvider);
10
- });
11
-
12
- afterEach(() => {
13
- CacheProviderRegistry.clear();
14
- });
15
-
16
- describe('register & get', () => {
17
- it('应注册并获取 Provider', () => {
18
- expect(CacheProviderRegistry.get('memory')).toBe(memoryProvider);
7
+ beforeEach(() => {
8
+ memoryProvider = new MemoryCacheProvider();
9
+ CacheProviderRegistry.register('memory', memoryProvider);
19
10
  });
20
11
 
21
- it('应在 Provider 不存在时抛出错误', () => {
22
- expect(() => CacheProviderRegistry.get('nonexistent')).toThrow();
12
+ afterEach(() => {
13
+ CacheProviderRegistry.clear();
23
14
  });
24
- });
25
15
 
26
- describe('setDefault & get', () => {
27
- it('应设置并获取默认 Provider', () => {
28
- CacheProviderRegistry.setDefault('memory');
29
- expect(CacheProviderRegistry.get()).toBe(memoryProvider);
16
+ describe('register & get', () => {
17
+ it('应注册并获取 Provider', () => {
18
+ expect(CacheProviderRegistry.get('memory')).toBe(memoryProvider);
19
+ });
20
+
21
+ it('应在 Provider 不存在时抛出错误', () => {
22
+ expect(() => CacheProviderRegistry.get('nonexistent')).toThrow();
23
+ });
30
24
  });
31
25
 
32
- it('应在未设置默认且未指定时抛出错误', () => {
33
- expect(() => CacheProviderRegistry.get()).toThrow();
26
+ describe('setDefault & get', () => {
27
+ it('应设置并获取默认 Provider', () => {
28
+ CacheProviderRegistry.setDefault('memory');
29
+ expect(CacheProviderRegistry.get()).toBe(memoryProvider);
30
+ });
31
+
32
+ it('应在未设置默认且未指定时抛出错误', () => {
33
+ expect(() => CacheProviderRegistry.get()).toThrow();
34
+ });
34
35
  });
35
- });
36
- });
36
+ });