@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +25 -11
  2. package/README.md +364 -270
  3. package/dist/core/cache-error.d.ts +75 -0
  4. package/dist/core/cache-error.d.ts.map +1 -0
  5. package/dist/core/cache-error.js +168 -0
  6. package/dist/core/cache-error.js.map +1 -0
  7. package/dist/core/cache-logger.d.ts +3 -2
  8. package/dist/core/cache-logger.d.ts.map +1 -1
  9. package/dist/core/cache-logger.js +2 -0
  10. package/dist/core/cache-logger.js.map +1 -1
  11. package/dist/decorators/cache-evict.d.ts.map +1 -1
  12. package/dist/decorators/cache-evict.js +15 -10
  13. package/dist/decorators/cache-evict.js.map +1 -1
  14. package/dist/decorators/cache.d.ts +6 -0
  15. package/dist/decorators/cache.d.ts.map +1 -1
  16. package/dist/decorators/cache.js +113 -36
  17. package/dist/decorators/cache.js.map +1 -1
  18. package/jest.config.js +11 -11
  19. package/package.json +1 -1
  20. package/src/adapters/ioredis-cache-client.ts +89 -89
  21. package/src/adapters/node-redis-cache-client.ts +95 -95
  22. package/src/adapters/redis-key-prefix.ts +38 -38
  23. package/src/core/cache-error.ts +233 -0
  24. package/src/core/cache-logger.ts +116 -105
  25. package/src/core/key-builder.ts +33 -33
  26. package/src/core/native-cache.ts +55 -55
  27. package/src/core/pending-cache.ts +29 -29
  28. package/src/core/redis-cache-client.ts +160 -160
  29. package/src/core/redis-cache.ts +104 -104
  30. package/src/decorators/cache-evict.ts +137 -129
  31. package/src/decorators/cache.ts +323 -203
  32. package/src/index.ts +11 -11
  33. package/test/cache-evict-logging.test.ts +398 -362
  34. package/test/cache-logger.integration.test.ts +159 -129
  35. package/test/cache-logger.test.ts +156 -153
  36. package/test/cache-logging.test.ts +929 -544
  37. package/test/cache.test.ts +1017 -255
  38. package/test/fixtures/cache-logger-child.mjs +108 -108
  39. package/test/fixtures/cache-provider-failure-child.mjs +70 -0
  40. package/test/helpers/legacy-redis-cache.ts +41 -22
  41. package/test/helpers/package-consumer.ts +231 -231
  42. package/test/helpers/redis-fixture.ts +142 -142
  43. package/test/ioredis-cache-client.test.ts +143 -143
  44. package/test/legacy-redis-cache.test.ts +51 -0
  45. package/test/native-cache.test.ts +77 -77
  46. package/test/node-redis-cache-client.test.ts +149 -149
  47. package/test/pending-cache.test.ts +69 -69
  48. package/test/redis-cache-client-lifecycle.test.ts +112 -112
  49. package/test/redis-cache-client-types.test.ts +184 -184
  50. package/test/redis-cache-client.integration.test.ts +355 -327
  51. package/test/redis-cache-decorator.test.ts +601 -201
  52. package/test/redis-cache-provider.test.ts +269 -269
  53. package/test/type-contract/contract.ts +119 -64
  54. package/test/type-contract/tsconfig.json +12 -12
  55. package/tsconfig.json +8 -8
@@ -1,255 +1,1017 @@
1
- import { Cache } from '../src/decorators/cache';
2
- import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
3
- import { MemoryCacheProvider } from '../src/core/native-cache';
4
-
5
- describe('@Cache 装饰器', () => {
6
- beforeAll(() => {
7
- CacheProviderRegistry.register('memory', new MemoryCacheProvider());
8
- CacheProviderRegistry.setDefault('memory');
9
- });
10
-
11
- beforeEach(() => {
12
- const provider = CacheProviderRegistry.get();
13
- provider.clear();
14
- });
15
-
16
- afterEach(() => {
17
- jest.useRealTimers();
18
- });
19
-
20
- describe('基础缓存', () => {
21
- it('应缓存方法结果', async () => {
22
- class UserService {
23
- callCount = 0;
24
-
25
- @Cache('user-cache')
26
- async getUser(id: number) {
27
- this.callCount++;
28
- return { id, name: 'test' };
29
- }
30
- }
31
-
32
- const service = new UserService();
33
- const result1 = await service.getUser(1);
34
- const result2 = await service.getUser(1);
35
-
36
- expect(result1).toEqual({ id: 1, name: 'test' });
37
- expect(result2).toEqual({ id: 1, name: 'test' });
38
- expect(service.callCount).toBe(1);
39
- });
40
-
41
- it('不同参数应分别缓存', async () => {
42
- class UserService {
43
- callCount = 0;
44
-
45
- @Cache('user-cache')
46
- async getUser(id: number) {
47
- this.callCount++;
48
- return { id };
49
- }
50
- }
51
-
52
- const service = new UserService();
53
- await service.getUser(1);
54
- await service.getUser(2);
55
-
56
- expect(service.callCount).toBe(2);
57
- });
58
- });
59
-
60
- describe('TTL 过期', () => {
61
- it('应在 TTL 后失效', async () => {
62
- jest.useFakeTimers();
63
-
64
- class UserService {
65
- callCount = 0;
66
-
67
- @Cache('user-cache', { ttl: 60 })
68
- async getUser(id: number) {
69
- this.callCount++;
70
- return { id };
71
- }
72
- }
73
-
74
- const service = new UserService();
75
- const result1 = await service.getUser(1);
76
- expect(result1).toEqual({ id: 1 });
77
-
78
- jest.advanceTimersByTime(60001);
79
-
80
- const result2 = await service.getUser(1);
81
- expect(result2).toEqual({ id: 1 });
82
- expect(service.callCount).toBe(2);
83
- });
84
- });
85
-
86
- describe('缓存命中', () => {
87
- it('应直接返回缓存值', async () => {
88
- class UserService {
89
- callCount = 0;
90
-
91
- @Cache('user-cache')
92
- async getUser(id: number) {
93
- this.callCount++;
94
- return { id, timestamp: Date.now() };
95
- }
96
- }
97
-
98
- const service = new UserService();
99
- const result1 = await service.getUser(1);
100
- const result2 = await service.getUser(1);
101
-
102
- expect(result1.id).toBe(1);
103
- expect(result2.id).toBe(1);
104
- expect(result1.timestamp).toBe(result2.timestamp);
105
- expect(service.callCount).toBe(1);
106
- });
107
- });
108
-
109
- describe('错误结果缓存', () => {
110
- it('应缓存错误结果并重新抛出原始异常', async () => {
111
- const expectedError = new Error('user not found');
112
-
113
- class UserService {
114
- callCount = 0;
115
-
116
- @Cache('user-cache')
117
- async getUser(_id: number) {
118
- this.callCount++;
119
- throw expectedError;
120
- }
121
- }
122
-
123
- const service = new UserService();
124
-
125
- await expect(service.getUser(1)).rejects.toBe(expectedError);
126
- await expect(service.getUser(1)).rejects.toBe(expectedError);
127
- expect(service.callCount).toBe(1);
128
- });
129
- });
130
-
131
- describe('请求合并', () => {
132
- it('并发请求应返回同一 Promise', async () => {
133
- let resolvePromise: ((value: number) => void) | undefined;
134
- let callCount = 0;
135
- const resultPromise = new Promise<number>((resolve) => {
136
- resolvePromise = resolve;
137
- });
138
-
139
- class UserService {
140
- @Cache('user-cache')
141
- async getUser(_id: number): Promise<number> {
142
- callCount++;
143
- return resultPromise;
144
- }
145
- }
146
-
147
- const service = new UserService();
148
- const promise1 = service.getUser(1);
149
- const promise2 = service.getUser(1);
150
-
151
- await Promise.resolve();
152
- resolvePromise!(42);
153
-
154
- const [result1, result2] = await Promise.all([promise1, promise2]);
155
- expect(callCount).toBe(1);
156
- expect(promise1).toBe(promise2);
157
- expect(result1).toBe(42);
158
- expect(result2).toBe(42);
159
- expect(callCount).toBe(1);
160
- });
161
- });
162
-
163
- describe('key 选项', () => {
164
- it('key 为 undefined 时使用自动生成逻辑', async () => {
165
- class UserService {
166
- callCount = 0;
167
-
168
- @Cache('user-cache', { key: undefined })
169
- async getUser(id: number) {
170
- this.callCount++;
171
- return { id };
172
- }
173
- }
174
-
175
- const service = new UserService();
176
- await service.getUser(1);
177
- await service.getUser(1);
178
- expect(service.callCount).toBe(1);
179
- });
180
-
181
- it('key 为 null 时使用自动生成逻辑', async () => {
182
- class UserService {
183
- callCount = 0;
184
-
185
- @Cache('user-cache', { key: null })
186
- async getUser(id: number) {
187
- this.callCount++;
188
- return { id };
189
- }
190
- }
191
-
192
- const service = new UserService();
193
- await service.getUser(1);
194
- await service.getUser(1);
195
- expect(service.callCount).toBe(1);
196
- });
197
-
198
- it('key 为字符串时使用字符串值', async () => {
199
- class UserService {
200
- callCount = 0;
201
-
202
- @Cache('user-cache', { key: 'specific-key' })
203
- async getUser(id: number) {
204
- this.callCount++;
205
- return { id };
206
- }
207
- }
208
-
209
- const service = new UserService();
210
- await service.getUser(1);
211
- await service.getUser(2);
212
- expect(service.callCount).toBe(1);
213
- });
214
-
215
- it('key 为函数时使用函数返回值', async () => {
216
- class UserService {
217
- callCount = 0;
218
-
219
- @Cache('user-cache', { key: (...args: unknown[]) => String(args[0]) })
220
- async getUser(id: number) {
221
- this.callCount++;
222
- return { id };
223
- }
224
- }
225
-
226
- const service = new UserService();
227
- await service.getUser(1);
228
- await service.getUser(1);
229
- expect(service.callCount).toBe(1);
230
- await service.getUser(2);
231
- expect(service.callCount).toBe(2);
232
- });
233
-
234
- it('key 函数抛出异常时降级到自动生成', async () => {
235
- class UserService {
236
- callCount = 0;
237
-
238
- @Cache('user-cache', {
239
- key: () => {
240
- throw new Error('bad key');
241
- },
242
- })
243
- async getUser(id: number) {
244
- this.callCount++;
245
- return { id };
246
- }
247
- }
248
-
249
- const service = new UserService();
250
- await service.getUser(1);
251
- await service.getUser(1);
252
- expect(service.callCount).toBe(1);
253
- });
254
- });
255
- });
1
+ import type { CacheProvider } from '../src/core/cache-provider';
2
+ import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
3
+ import { KeyBuilder } from '../src/core/key-builder';
4
+ import { MemoryCacheProvider } from '../src/core/native-cache';
5
+ import { Cache, type CacheErrorCodec, type CacheErrorPolicy, type CacheOptions } from '../src/decorators/cache';
6
+
7
+ interface ProviderHarness {
8
+ readonly provider: CacheProvider;
9
+ readonly get: jest.Mock<unknown, [string]>;
10
+ readonly set: jest.Mock<void, [string, unknown, number?]>;
11
+ readonly values: Map<string, unknown>;
12
+ }
13
+
14
+ interface RejectionDeferred {
15
+ readonly promise: Promise<never>;
16
+ readonly reject: (reason: unknown) => void;
17
+ }
18
+
19
+ /** 创建同步读写且记录调用参数的可插拔 Provider。 */
20
+ function createProvider(): ProviderHarness {
21
+ const values = new Map<string, unknown>();
22
+ const get = jest.fn<unknown, [string]>((key) => values.get(key));
23
+ const set = jest.fn<void, [string, unknown, number?]>((key, value) => {
24
+ values.set(key, value);
25
+ });
26
+ const provider: CacheProvider = {
27
+ get: <Value>(key: string): Value | undefined => get(key) as Value | undefined,
28
+ set: <Value>(key: string, value: Value, ttl?: number): void => set(key, value, ttl),
29
+ delete: (key: string): void => {
30
+ values.delete(key);
31
+ },
32
+ clear: (): void => {
33
+ values.clear();
34
+ },
35
+ deleteByPattern: (): void => {
36
+ values.clear();
37
+ },
38
+ };
39
+ return { provider, get, set, values };
40
+ }
41
+
42
+ /** 创建由测试显式拒绝的 Promise,用于观察 pending 身份与清理。 */
43
+ function createRejectionDeferred(): RejectionDeferred {
44
+ let rejectPromise: ((reason: unknown) => void) | undefined;
45
+ const promise = new Promise<never>((_resolve, reject) => {
46
+ rejectPromise = reject;
47
+ });
48
+ if (!rejectPromise) {
49
+ throw new Error('Deferred rejecter was not initialized');
50
+ }
51
+ return { promise, reject: rejectPromise };
52
+ }
53
+
54
+ /** 等待并返回 Promise 的 rejection 值,包括 undefined 等非 Error 值。 */
55
+ async function captureRejection(promise: Promise<unknown>): Promise<unknown> {
56
+ try {
57
+ await promise;
58
+ } catch (error) {
59
+ return error;
60
+ }
61
+ throw new Error('Expected promise to reject');
62
+ }
63
+
64
+ /** 使用公开协议形状构造当前版本异常条目,避免测试依赖内部 helper。 */
65
+ function currentErrorEntry(payload: unknown): unknown {
66
+ return {
67
+ error: {
68
+ kind: '@jintianxiayu/cache-decorator/error',
69
+ version: 1,
70
+ payload,
71
+ },
72
+ };
73
+ }
74
+
75
+ beforeEach(() => {
76
+ CacheProviderRegistry.clear();
77
+ CacheProviderRegistry.register('memory', new MemoryCacheProvider());
78
+ CacheProviderRegistry.setDefault('memory');
79
+ });
80
+
81
+ afterEach(() => {
82
+ jest.useRealTimers();
83
+ jest.restoreAllMocks();
84
+ CacheProviderRegistry.clear();
85
+ });
86
+
87
+ describe('@Cache 装饰器', () => {
88
+ describe('基础缓存', () => {
89
+ it('应缓存方法结果', async () => {
90
+ class UserService {
91
+ callCount = 0;
92
+
93
+ @Cache('user-cache')
94
+ async getUser(id: number) {
95
+ this.callCount++;
96
+ return { id, name: 'test' };
97
+ }
98
+ }
99
+
100
+ const service = new UserService();
101
+ const result1 = await service.getUser(1);
102
+ const result2 = await service.getUser(1);
103
+
104
+ expect(result1).toEqual({ id: 1, name: 'test' });
105
+ expect(result2).toEqual({ id: 1, name: 'test' });
106
+ expect(service.callCount).toBe(1);
107
+ });
108
+
109
+ it('不同参数应分别缓存', async () => {
110
+ class UserService {
111
+ callCount = 0;
112
+
113
+ @Cache('user-cache')
114
+ async getUser(id: number) {
115
+ this.callCount++;
116
+ return { id };
117
+ }
118
+ }
119
+
120
+ const service = new UserService();
121
+ await service.getUser(1);
122
+ await service.getUser(2);
123
+
124
+ expect(service.callCount).toBe(2);
125
+ });
126
+ });
127
+
128
+ describe('TTL 过期', () => {
129
+ it('应在 TTL 后失效', async () => {
130
+ jest.useFakeTimers();
131
+
132
+ class UserService {
133
+ callCount = 0;
134
+
135
+ @Cache('user-cache', { ttl: 60 })
136
+ async getUser(id: number) {
137
+ this.callCount++;
138
+ return { id };
139
+ }
140
+ }
141
+
142
+ const service = new UserService();
143
+ const result1 = await service.getUser(1);
144
+ expect(result1).toEqual({ id: 1 });
145
+
146
+ jest.advanceTimersByTime(60001);
147
+
148
+ const result2 = await service.getUser(1);
149
+ expect(result2).toEqual({ id: 1 });
150
+ expect(service.callCount).toBe(2);
151
+ });
152
+ });
153
+
154
+ describe('缓存命中', () => {
155
+ it('应直接返回缓存值', async () => {
156
+ class UserService {
157
+ callCount = 0;
158
+
159
+ @Cache('user-cache')
160
+ async getUser(id: number) {
161
+ this.callCount++;
162
+ return { id, timestamp: Date.now() };
163
+ }
164
+ }
165
+
166
+ const service = new UserService();
167
+ const result1 = await service.getUser(1);
168
+ const result2 = await service.getUser(1);
169
+
170
+ expect(result1.id).toBe(1);
171
+ expect(result2.id).toBe(1);
172
+ expect(result1.timestamp).toBe(result2.timestamp);
173
+ expect(service.callCount).toBe(1);
174
+ });
175
+ });
176
+
177
+ describe('错误结果缓存', () => {
178
+ it('省略策略时不持久化业务异常并允许顺序重试', async () => {
179
+ class UserService {
180
+ callCount = 0;
181
+
182
+ @Cache('user-cache')
183
+ async getUser(_id: number) {
184
+ this.callCount++;
185
+ throw new Error(`user not found ${this.callCount}`);
186
+ }
187
+ }
188
+
189
+ const service = new UserService();
190
+
191
+ await expect(service.getUser(1)).rejects.toThrow('user not found 1');
192
+ await expect(service.getUser(1)).rejects.toThrow('user not found 2');
193
+ expect(service.callCount).toBe(2);
194
+ });
195
+ });
196
+
197
+ describe('请求合并', () => {
198
+ it('并发请求应返回同一 Promise', async () => {
199
+ let resolvePromise: ((value: number) => void) | undefined;
200
+ let callCount = 0;
201
+ const resultPromise = new Promise<number>((resolve) => {
202
+ resolvePromise = resolve;
203
+ });
204
+
205
+ class UserService {
206
+ @Cache('user-cache')
207
+ async getUser(_id: number): Promise<number> {
208
+ callCount++;
209
+ return resultPromise;
210
+ }
211
+ }
212
+
213
+ const service = new UserService();
214
+ const promise1 = service.getUser(1);
215
+ const promise2 = service.getUser(1);
216
+
217
+ await Promise.resolve();
218
+ resolvePromise!(42);
219
+
220
+ const [result1, result2] = await Promise.all([promise1, promise2]);
221
+ expect(callCount).toBe(1);
222
+ expect(promise1).toBe(promise2);
223
+ expect(result1).toBe(42);
224
+ expect(result2).toBe(42);
225
+ expect(callCount).toBe(1);
226
+ });
227
+ });
228
+
229
+ describe('key 选项', () => {
230
+ it('key 为 undefined 时使用自动生成逻辑', async () => {
231
+ class UserService {
232
+ callCount = 0;
233
+
234
+ @Cache('user-cache', { key: undefined })
235
+ async getUser(id: number) {
236
+ this.callCount++;
237
+ return { id };
238
+ }
239
+ }
240
+
241
+ const service = new UserService();
242
+ await service.getUser(1);
243
+ await service.getUser(1);
244
+ expect(service.callCount).toBe(1);
245
+ });
246
+
247
+ it('key 为 null 时使用自动生成逻辑', async () => {
248
+ class UserService {
249
+ callCount = 0;
250
+
251
+ @Cache('user-cache', { key: null })
252
+ async getUser(id: number) {
253
+ this.callCount++;
254
+ return { id };
255
+ }
256
+ }
257
+
258
+ const service = new UserService();
259
+ await service.getUser(1);
260
+ await service.getUser(1);
261
+ expect(service.callCount).toBe(1);
262
+ });
263
+
264
+ it('key 为字符串时使用字符串值', async () => {
265
+ class UserService {
266
+ callCount = 0;
267
+
268
+ @Cache('user-cache', { key: 'specific-key' })
269
+ async getUser(id: number) {
270
+ this.callCount++;
271
+ return { id };
272
+ }
273
+ }
274
+
275
+ const service = new UserService();
276
+ await service.getUser(1);
277
+ await service.getUser(2);
278
+ expect(service.callCount).toBe(1);
279
+ });
280
+
281
+ it('key 为函数时使用函数返回值', async () => {
282
+ class UserService {
283
+ callCount = 0;
284
+
285
+ @Cache('user-cache', { key: (...args: unknown[]) => String(args[0]) })
286
+ async getUser(id: number) {
287
+ this.callCount++;
288
+ return { id };
289
+ }
290
+ }
291
+
292
+ const service = new UserService();
293
+ await service.getUser(1);
294
+ await service.getUser(1);
295
+ expect(service.callCount).toBe(1);
296
+ await service.getUser(2);
297
+ expect(service.callCount).toBe(2);
298
+ });
299
+
300
+ it('key 函数抛出异常时降级到自动生成', async () => {
301
+ class UserService {
302
+ callCount = 0;
303
+
304
+ @Cache('user-cache', {
305
+ key: () => {
306
+ throw new Error('bad key');
307
+ },
308
+ })
309
+ async getUser(id: number) {
310
+ this.callCount++;
311
+ return { id };
312
+ }
313
+ }
314
+
315
+ const service = new UserService();
316
+ await service.getUser(1);
317
+ await service.getUser(1);
318
+ expect(service.callCount).toBe(1);
319
+ });
320
+ });
321
+ });
322
+
323
+ describe('异常策略配置边界', () => {
324
+ it('非法异常 TTL 在 decorator 求值时抛出 RangeError 且不访问 Provider 或业务方法', () => {
325
+ const providerGet = jest.spyOn(CacheProviderRegistry, 'get');
326
+ const businessMethod = jest.fn();
327
+ const invalidTtls = [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
328
+
329
+ for (const ttl of invalidTtls) {
330
+ expect(() => Cache('invalid-error-ttl', { errorCache: { ttl } })).toThrow(RangeError);
331
+ }
332
+
333
+ expect(providerGet).not.toHaveBeenCalled();
334
+ expect(businessMethod).not.toHaveBeenCalled();
335
+ });
336
+
337
+ it('运行期畸形筛选器和 codec 在 decorator 求值时抛出 TypeError', () => {
338
+ const invalidPolicies: readonly unknown[] = [
339
+ null,
340
+ { ttl: 5, shouldCache: 'yes' },
341
+ { ttl: 5, codec: null },
342
+ { ttl: 5, codec: { encode: (): unknown => ({}) } },
343
+ { ttl: 5, codec: { decode: (): unknown => new Error('decoded') } },
344
+ ];
345
+
346
+ for (const policy of invalidPolicies) {
347
+ expect(() => Cache('invalid-error-policy', { errorCache: policy as CacheErrorPolicy })).toThrow(TypeError);
348
+ }
349
+ });
350
+ });
351
+
352
+ describe('异常策略编排', () => {
353
+ it('Provider 同步读取失败时旁路缓存并执行业务方法', async () => {
354
+ const provider = createProvider();
355
+ const readError = new Error('redis unavailable');
356
+ provider.get.mockImplementation(() => {
357
+ throw readError;
358
+ });
359
+ CacheProviderRegistry.register('failed-read', provider.provider);
360
+ const shouldCache = jest.fn(() => true);
361
+ const encode = jest.fn((error: unknown): unknown => error);
362
+ const decode = jest.fn((payload: unknown): unknown => payload);
363
+ const businessResult = { id: 1 };
364
+ const businessMethod = jest.fn(() => businessResult);
365
+
366
+ class UserService {
367
+ @Cache('failed-read-users', {
368
+ providerName: 'failed-read',
369
+ errorCache: { ttl: 5, shouldCache, codec: { encode, decode } },
370
+ })
371
+ getUser(): unknown {
372
+ return businessMethod();
373
+ }
374
+ }
375
+
376
+ await expect(new UserService().getUser()).resolves.toBe(businessResult);
377
+ expect(businessMethod).toHaveBeenCalledTimes(1);
378
+ expect(shouldCache).not.toHaveBeenCalled();
379
+ expect(encode).not.toHaveBeenCalled();
380
+ expect(decode).not.toHaveBeenCalled();
381
+ expect(provider.set).not.toHaveBeenCalled();
382
+ });
383
+ });
384
+
385
+ describe('Provider 故障旁路', () => {
386
+ it.each(['missing', 'sync-read', 'async-read'] as const)(
387
+ 'Provider %s 故障旁路时传播原始业务异常且不执行异常策略',
388
+ async (failureMode) => {
389
+ const provider = createProvider();
390
+ const providerError = new Error(`provider ${failureMode}`);
391
+ if (failureMode === 'sync-read') {
392
+ provider.get.mockImplementation(() => {
393
+ throw providerError;
394
+ });
395
+ CacheProviderRegistry.register('failed-provider', provider.provider);
396
+ } else if (failureMode === 'async-read') {
397
+ provider.get.mockRejectedValue(providerError);
398
+ CacheProviderRegistry.register('failed-provider', provider.provider);
399
+ }
400
+
401
+ const businessError = new Error(`business ${failureMode}`);
402
+ const shouldCache = jest.fn((_error: unknown): boolean => true);
403
+ const encode = jest.fn((error: unknown): unknown => error);
404
+ const decode = jest.fn((payload: unknown): unknown => payload);
405
+ const businessMethod = jest.fn(() => {
406
+ throw businessError;
407
+ });
408
+
409
+ class UserService {
410
+ @Cache('failed-provider-users', {
411
+ providerName: 'failed-provider',
412
+ errorCache: { ttl: 5, shouldCache, codec: { encode, decode } },
413
+ })
414
+ getUser(): never {
415
+ return businessMethod();
416
+ }
417
+ }
418
+
419
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
420
+ expect(businessMethod).toHaveBeenCalledTimes(1);
421
+ expect(provider.get).toHaveBeenCalledTimes(failureMode === 'missing' ? 0 : 1);
422
+ expect(provider.set).not.toHaveBeenCalled();
423
+ expect(shouldCache).not.toHaveBeenCalled();
424
+ expect(encode).not.toHaveBeenCalled();
425
+ expect(decode).not.toHaveBeenCalled();
426
+ }
427
+ );
428
+
429
+ it('Provider 异步读取失败时并发调用复用 pending Promise 且 settled 后清理', async () => {
430
+ const provider = createProvider();
431
+ const readFailure = createRejectionDeferred();
432
+ provider.get.mockReturnValue(readFailure.promise);
433
+ CacheProviderRegistry.register('pending-failed-read', provider.provider);
434
+ const businessMethod = jest.fn(() => 23);
435
+
436
+ class UserService {
437
+ @Cache('pending-failed-read-users', { providerName: 'pending-failed-read' })
438
+ getUser(): number {
439
+ return businessMethod();
440
+ }
441
+ }
442
+
443
+ const service = new UserService();
444
+ const first = service.getUser();
445
+ const second = service.getUser();
446
+ expect(second).toBe(first);
447
+ expect(provider.get).toHaveBeenCalledTimes(1);
448
+
449
+ readFailure.reject(new Error('redis read failed'));
450
+ await expect(Promise.all([first, second])).resolves.toEqual([23, 23]);
451
+ expect(businessMethod).toHaveBeenCalledTimes(1);
452
+ expect(provider.set).not.toHaveBeenCalled();
453
+
454
+ await expect(service.getUser()).resolves.toBe(23);
455
+ expect(provider.get).toHaveBeenCalledTimes(2);
456
+ expect(businessMethod).toHaveBeenCalledTimes(2);
457
+ });
458
+ });
459
+
460
+ describe('异常策略编排', () => {
461
+ it('key resolver 失败后的业务成功只写入正常结果', async () => {
462
+ const provider = createProvider();
463
+ CacheProviderRegistry.register('key-fallback', provider.provider);
464
+ const shouldCache = jest.fn(() => true);
465
+ const codec: CacheErrorCodec = {
466
+ encode: jest.fn((error: unknown): unknown => error),
467
+ decode: jest.fn((payload: unknown): unknown => payload),
468
+ };
469
+
470
+ class UserService {
471
+ @Cache('key-fallback-users', {
472
+ providerName: 'key-fallback',
473
+ key: () => {
474
+ throw new Error('resolver failed');
475
+ },
476
+ errorCache: { ttl: 5, shouldCache, codec },
477
+ })
478
+ async getUser(id: number): Promise<{ id: number }> {
479
+ return { id };
480
+ }
481
+ }
482
+
483
+ await expect(new UserService().getUser(7)).resolves.toEqual({ id: 7 });
484
+ expect(shouldCache).not.toHaveBeenCalled();
485
+ expect(codec.encode).not.toHaveBeenCalled();
486
+ expect(provider.set).toHaveBeenCalledWith(expect.any(String), { value: { id: 7 } }, undefined);
487
+ });
488
+
489
+ it('省略筛选器时接受异常,命中使用默认 codec 的新 Error', async () => {
490
+ const businessError = new Error('stable not found');
491
+
492
+ class UserService {
493
+ callCount = 0;
494
+
495
+ @Cache('default-error-policy', { errorCache: { ttl: 5 } })
496
+ async getUser(): Promise<never> {
497
+ this.callCount += 1;
498
+ throw businessError;
499
+ }
500
+ }
501
+
502
+ const service = new UserService();
503
+ expect(await captureRejection(service.getUser())).toBe(businessError);
504
+ const cachedError = await captureRejection(service.getUser());
505
+ expect(cachedError).toBeInstanceOf(Error);
506
+ expect(cachedError).not.toBe(businessError);
507
+ expect((cachedError as Error).message).toBe('stable not found');
508
+ expect(service.callCount).toBe(1);
509
+ });
510
+
511
+ it('筛选器返回 true 时只求值和 encode 一次并使用异常 TTL', async () => {
512
+ const provider = createProvider();
513
+ CacheProviderRegistry.register('accepted-error', provider.provider);
514
+ const businessError = new Error('accepted');
515
+ const shouldCache = jest.fn((_error: unknown): boolean => true);
516
+ const encode = jest.fn((_error: unknown): unknown => ({ code: 'ACCEPTED' }));
517
+ const codec: CacheErrorCodec = {
518
+ encode,
519
+ decode: (payload: unknown): unknown => payload,
520
+ };
521
+
522
+ class UserService {
523
+ @Cache('accepted-error-users', {
524
+ providerName: 'accepted-error',
525
+ errorCache: { ttl: 9, shouldCache, codec },
526
+ })
527
+ async getUser(): Promise<never> {
528
+ throw businessError;
529
+ }
530
+ }
531
+
532
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
533
+ expect(shouldCache).toHaveBeenCalledTimes(1);
534
+ expect(shouldCache).toHaveBeenCalledWith(businessError);
535
+ expect(encode).toHaveBeenCalledTimes(1);
536
+ expect(provider.set).toHaveBeenCalledTimes(1);
537
+ expect(provider.set.mock.calls[0]?.[2]).toBe(9);
538
+ });
539
+
540
+ it('筛选器返回 false 时跳过 codec 和写入并允许后续重试', async () => {
541
+ const provider = createProvider();
542
+ CacheProviderRegistry.register('rejected-error', provider.provider);
543
+ const shouldCache = jest.fn((_error: unknown): boolean => false);
544
+ const codec: CacheErrorCodec = {
545
+ encode: jest.fn((error: unknown): unknown => error),
546
+ decode: jest.fn((payload: unknown): unknown => payload),
547
+ };
548
+ let callCount = 0;
549
+
550
+ class UserService {
551
+ @Cache('rejected-error-users', {
552
+ providerName: 'rejected-error',
553
+ errorCache: { ttl: 9, shouldCache, codec },
554
+ })
555
+ async getUser(): Promise<never> {
556
+ callCount += 1;
557
+ throw new Error(`transient ${callCount}`);
558
+ }
559
+ }
560
+
561
+ const service = new UserService();
562
+ await expect(service.getUser()).rejects.toThrow('transient 1');
563
+ await expect(service.getUser()).rejects.toThrow('transient 2');
564
+ expect(shouldCache).toHaveBeenCalledTimes(2);
565
+ expect(codec.encode).not.toHaveBeenCalled();
566
+ expect(provider.set).not.toHaveBeenCalled();
567
+ expect(callCount).toBe(2);
568
+ });
569
+
570
+ it('筛选器抛错时 fail-closed 且不遮蔽原业务异常', async () => {
571
+ const provider = createProvider();
572
+ CacheProviderRegistry.register('failed-predicate', provider.provider);
573
+ const businessError = new Error('original business failure');
574
+ const shouldCache = jest.fn(() => {
575
+ throw new Error('predicate secret');
576
+ });
577
+ const codec: CacheErrorCodec = {
578
+ encode: jest.fn((error: unknown): unknown => error),
579
+ decode: jest.fn((payload: unknown): unknown => payload),
580
+ };
581
+
582
+ class UserService {
583
+ @Cache('failed-predicate-users', {
584
+ providerName: 'failed-predicate',
585
+ errorCache: { ttl: 9, shouldCache, codec },
586
+ })
587
+ async getUser(): Promise<never> {
588
+ throw businessError;
589
+ }
590
+ }
591
+
592
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
593
+ expect(shouldCache).toHaveBeenCalledTimes(1);
594
+ expect(codec.encode).not.toHaveBeenCalled();
595
+ expect(provider.set).not.toHaveBeenCalled();
596
+ });
597
+
598
+ it('正常结果和异常分别使用正常 TTL 与异常 TTL', async () => {
599
+ const provider = createProvider();
600
+ CacheProviderRegistry.register('separate-ttl', provider.provider);
601
+ const businessError = new Error('negative result');
602
+
603
+ class UserService {
604
+ @Cache('normal-ttl-users', {
605
+ providerName: 'separate-ttl',
606
+ ttl: 300,
607
+ errorCache: { ttl: 10 },
608
+ })
609
+ async getUser(): Promise<string> {
610
+ return 'value';
611
+ }
612
+
613
+ @Cache('error-ttl-users', {
614
+ providerName: 'separate-ttl',
615
+ ttl: 300,
616
+ errorCache: { ttl: 10 },
617
+ })
618
+ async getMissingUser(): Promise<never> {
619
+ throw businessError;
620
+ }
621
+
622
+ @Cache('omitted-normal-ttl', { providerName: 'separate-ttl', errorCache: { ttl: 10 } })
623
+ async getConfig(): Promise<string> {
624
+ return 'config';
625
+ }
626
+ }
627
+
628
+ const service = new UserService();
629
+ await expect(service.getUser()).resolves.toBe('value');
630
+ await expect(service.getMissingUser()).rejects.toBe(businessError);
631
+ await expect(service.getConfig()).resolves.toBe('config');
632
+
633
+ expect(provider.set.mock.calls.map((call) => call[2])).toEqual([300, 10, undefined]);
634
+ });
635
+
636
+ it('Memory Provider 中异常 TTL 到期后重新执行业务方法', async () => {
637
+ jest.useFakeTimers({ now: 0 });
638
+
639
+ class UserService {
640
+ callCount = 0;
641
+
642
+ @Cache('expiring-errors', { errorCache: { ttl: 10 } })
643
+ async getUser(): Promise<never> {
644
+ this.callCount += 1;
645
+ throw new Error(`failure ${this.callCount}`);
646
+ }
647
+ }
648
+
649
+ const service = new UserService();
650
+ await expect(service.getUser()).rejects.toThrow('failure 1');
651
+ await expect(service.getUser()).rejects.toThrow('failure 1');
652
+ expect(service.callCount).toBe(1);
653
+
654
+ jest.advanceTimersByTime(10_001);
655
+ await expect(service.getUser()).rejects.toThrow('failure 2');
656
+ expect(service.callCount).toBe(2);
657
+ });
658
+
659
+ it('异常写入同步失败仍传播原业务异常', async () => {
660
+ const provider = createProvider();
661
+ const writeError = new Error('synchronous cache write failed');
662
+ const businessError = new Error('business failure');
663
+ provider.set.mockImplementation(() => {
664
+ throw writeError;
665
+ });
666
+ CacheProviderRegistry.register('failed-error-write', provider.provider);
667
+
668
+ class UserService {
669
+ @Cache('failed-error-write-users', {
670
+ providerName: 'failed-error-write',
671
+ errorCache: { ttl: 5 },
672
+ })
673
+ async getUser(): Promise<never> {
674
+ throw businessError;
675
+ }
676
+ }
677
+
678
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
679
+ expect(provider.set).toHaveBeenCalledTimes(1);
680
+ });
681
+
682
+ it('正常写入同步失败不进入异常缓存策略且保留业务结果', async () => {
683
+ const provider = createProvider();
684
+ const writeError = new Error('normal write failed');
685
+ const shouldCache = jest.fn((_error: unknown): boolean => true);
686
+ provider.set.mockImplementation(() => {
687
+ throw writeError;
688
+ });
689
+ CacheProviderRegistry.register('failed-normal-write', provider.provider);
690
+
691
+ class UserService {
692
+ @Cache('failed-normal-write-users', {
693
+ providerName: 'failed-normal-write',
694
+ errorCache: { ttl: 5, shouldCache },
695
+ })
696
+ async getUser(): Promise<string> {
697
+ return 'value';
698
+ }
699
+ }
700
+
701
+ await expect(new UserService().getUser()).resolves.toBe('value');
702
+ expect(provider.set).toHaveBeenCalledTimes(1);
703
+ expect(shouldCache).not.toHaveBeenCalled();
704
+ });
705
+ });
706
+
707
+ describe('异常表示与命中分类', () => {
708
+ it('默认 codec 仅持久化标准 Error 的 name/message 并在命中时创建新 Error', async () => {
709
+ const provider = createProvider();
710
+ CacheProviderRegistry.register('default-codec', provider.provider);
711
+ const businessError = new Error('user 13800138000 not found');
712
+ businessError.name = 'NotFoundError';
713
+ businessError.stack = 'sensitive stack';
714
+ Object.assign(businessError, { internalCode: 'SECRET_CODE' });
715
+ let callCount = 0;
716
+
717
+ class UserService {
718
+ @Cache('default-codec-users', { providerName: 'default-codec', errorCache: { ttl: 5 } })
719
+ async getUser(): Promise<never> {
720
+ callCount += 1;
721
+ throw businessError;
722
+ }
723
+ }
724
+
725
+ const service = new UserService();
726
+ expect(await captureRejection(service.getUser())).toBe(businessError);
727
+ const storedEntry = [...provider.values.values()][0];
728
+ expect(storedEntry).toEqual(
729
+ currentErrorEntry({ type: 'error', name: 'NotFoundError', message: 'user 13800138000 not found' })
730
+ );
731
+ expect(JSON.stringify(storedEntry)).not.toContain('sensitive stack');
732
+ expect(JSON.stringify(storedEntry)).not.toContain('SECRET_CODE');
733
+
734
+ const cachedError = await captureRejection(service.getUser());
735
+ expect(cachedError).toBeInstanceOf(Error);
736
+ expect(cachedError).not.toBe(businessError);
737
+ expect((cachedError as Error).name).toBe('NotFoundError');
738
+ expect((cachedError as Error).message).toBe('user 13800138000 not found');
739
+ expect((cachedError as { internalCode?: string }).internalCode).toBeUndefined();
740
+ expect(callCount).toBe(1);
741
+ });
742
+
743
+ it('默认 codec 让 JSON 兼容的非 Error 值按 JSON 表示往返', async () => {
744
+ const cases: ReadonlyArray<readonly [string, unknown]> = [
745
+ ['string', 'NOT_FOUND'],
746
+ ['number', 404],
747
+ ['boolean', false],
748
+ ['null', null],
749
+ ['object', { code: 'NOT_FOUND', details: [1, true, null] }],
750
+ ];
751
+
752
+ for (const [label, thrownValue] of cases) {
753
+ const provider = createProvider();
754
+ const providerName = `json-error-${label}`;
755
+ CacheProviderRegistry.register(providerName, provider.provider);
756
+ let callCount = 0;
757
+
758
+ class UserService {
759
+ @Cache(`json-error-${label}`, { providerName, errorCache: { ttl: 5 } })
760
+ async getUser(): Promise<never> {
761
+ callCount += 1;
762
+ throw thrownValue;
763
+ }
764
+ }
765
+
766
+ const service = new UserService();
767
+ expect(await captureRejection(service.getUser())).toBe(thrownValue);
768
+ expect(await captureRejection(service.getUser())).toEqual(thrownValue);
769
+ expect(callCount).toBe(1);
770
+ }
771
+ });
772
+
773
+ it('默认 codec 对不可 JSON 往返的值跳过写入并保留原异常', async () => {
774
+ const cyclic: Record<string, unknown> = {};
775
+ cyclic.self = cyclic;
776
+ const invalidValues: ReadonlyArray<readonly [string, unknown]> = [
777
+ ['undefined', undefined],
778
+ ['function', (): void => undefined],
779
+ ['symbol', Symbol('secret')],
780
+ ['cyclic', cyclic],
781
+ ['non-finite', Number.NaN],
782
+ ['bigint', BigInt(1)],
783
+ ];
784
+
785
+ for (const [label, thrownValue] of invalidValues) {
786
+ const provider = createProvider();
787
+ const providerName = `invalid-error-${label}`;
788
+ CacheProviderRegistry.register(providerName, provider.provider);
789
+ let callCount = 0;
790
+
791
+ class UserService {
792
+ @Cache(`invalid-error-${label}`, { providerName, key: 'fixed', errorCache: { ttl: 5 } })
793
+ async getUser(): Promise<never> {
794
+ callCount += 1;
795
+ throw thrownValue;
796
+ }
797
+ }
798
+
799
+ const service = new UserService();
800
+ expect(await captureRejection(service.getUser())).toBe(thrownValue);
801
+ expect(await captureRejection(service.getUser())).toBe(thrownValue);
802
+ expect(provider.set).not.toHaveBeenCalled();
803
+ expect(callCount).toBe(2);
804
+ }
805
+ });
806
+
807
+ it('自定义 codec 在 Memory 与自定义 Provider 中得到同一规范化 payload', async () => {
808
+ class DomainError extends Error {
809
+ constructor(readonly code: string) {
810
+ super(`domain:${code}`);
811
+ this.name = 'DomainError';
812
+ }
813
+ }
814
+
815
+ const encodedPayload = { code: 'NOT_FOUND' };
816
+ const encode = jest.fn((_error: unknown): unknown => encodedPayload);
817
+ const decode = jest.fn((payload: unknown): unknown => {
818
+ if (typeof payload !== 'object' || payload === null || !('code' in payload)) {
819
+ throw new TypeError('invalid domain payload');
820
+ }
821
+ return new DomainError(String(payload.code));
822
+ });
823
+ const codec: CacheErrorCodec = { encode, decode };
824
+ const customProvider = createProvider();
825
+ CacheProviderRegistry.register('custom-codec-provider', customProvider.provider);
826
+ let memoryCalls = 0;
827
+ let customCalls = 0;
828
+
829
+ class UserService {
830
+ @Cache('custom-codec-memory', { providerName: 'memory', errorCache: { ttl: 5, codec } })
831
+ async getFromMemory(): Promise<never> {
832
+ memoryCalls += 1;
833
+ throw new DomainError('NOT_FOUND');
834
+ }
835
+
836
+ @Cache('custom-codec-provider', {
837
+ providerName: 'custom-codec-provider',
838
+ errorCache: { ttl: 5, codec },
839
+ })
840
+ async getFromCustomProvider(): Promise<never> {
841
+ customCalls += 1;
842
+ throw new DomainError('NOT_FOUND');
843
+ }
844
+ }
845
+
846
+ const service = new UserService();
847
+ await captureRejection(service.getFromMemory());
848
+ await captureRejection(service.getFromCustomProvider());
849
+ const memoryEntry = await CacheProviderRegistry.get('memory').get(KeyBuilder.build('custom-codec-memory', []));
850
+ const customEntry = [...customProvider.values.values()][0];
851
+ expect(memoryEntry).toEqual(customEntry);
852
+ expect((memoryEntry as { error: { payload: unknown } }).error.payload).toEqual(encodedPayload);
853
+ expect((memoryEntry as { error: { payload: unknown } }).error.payload).not.toBe(encodedPayload);
854
+
855
+ const memoryError = await captureRejection(service.getFromMemory());
856
+ const customError = await captureRejection(service.getFromCustomProvider());
857
+ expect(memoryError).toBeInstanceOf(DomainError);
858
+ expect(customError).toBeInstanceOf(DomainError);
859
+ expect(encode).toHaveBeenCalledTimes(2);
860
+ expect(decode).toHaveBeenCalledTimes(2);
861
+ expect(memoryCalls).toBe(1);
862
+ expect(customCalls).toBe(1);
863
+ });
864
+
865
+ it('自定义 encode 抛错时跳过写入并保留业务异常', async () => {
866
+ const provider = createProvider();
867
+ CacheProviderRegistry.register('failed-encode', provider.provider);
868
+ const businessError = new Error('business failure');
869
+ const codecError = new Error('codec secret');
870
+ const codec: CacheErrorCodec = {
871
+ encode: (): never => {
872
+ throw codecError;
873
+ },
874
+ decode: (payload: unknown): unknown => payload,
875
+ };
876
+
877
+ class UserService {
878
+ @Cache('failed-encode-users', { providerName: 'failed-encode', errorCache: { ttl: 5, codec } })
879
+ async getUser(): Promise<never> {
880
+ throw businessError;
881
+ }
882
+ }
883
+
884
+ await expect(new UserService().getUser()).rejects.toBe(businessError);
885
+ expect(provider.set).not.toHaveBeenCalled();
886
+ });
887
+
888
+ it('共享 key 的 codec 不匹配或 decode 返回非法结果时旁路为 miss', async () => {
889
+ const cases: ReadonlyArray<readonly [string, (payload: unknown) => unknown]> = [
890
+ [
891
+ 'mismatch',
892
+ (): never => {
893
+ throw new TypeError('codec mismatch');
894
+ },
895
+ ],
896
+ ['invalid-result', (): unknown => (): void => undefined],
897
+ ];
898
+
899
+ for (const [label, decode] of cases) {
900
+ const provider = createProvider();
901
+ const providerName = `decode-${label}`;
902
+ const cacheName = `decode-${label}-users`;
903
+ provider.values.set(KeyBuilder.build(cacheName, []), currentErrorEntry({ code: 'OTHER_CODEC' }));
904
+ CacheProviderRegistry.register(providerName, provider.provider);
905
+ const businessMethod = jest.fn(() => 'fresh');
906
+
907
+ class UserService {
908
+ @Cache(cacheName, {
909
+ providerName,
910
+ errorCache: { ttl: 5, codec: { encode: (error: unknown): unknown => error, decode } },
911
+ })
912
+ async getUser(): Promise<string> {
913
+ return businessMethod();
914
+ }
915
+ }
916
+
917
+ await expect(new UserService().getUser()).resolves.toBe('fresh');
918
+ expect(businessMethod).toHaveBeenCalledTimes(1);
919
+ expect(provider.set).toHaveBeenCalledWith(expect.any(String), { value: 'fresh' }, undefined);
920
+ }
921
+ });
922
+
923
+ it('禁用策略时旁路版本化异常并用成功结果覆盖', async () => {
924
+ const provider = createProvider();
925
+ const cacheName = 'disabled-versioned-error';
926
+ const cacheKey = KeyBuilder.build(cacheName, []);
927
+ provider.values.set(cacheKey, currentErrorEntry({ type: 'error', name: 'Error', message: 'cached' }));
928
+ CacheProviderRegistry.register('disabled-versioned', provider.provider);
929
+ const businessMethod = jest.fn(() => 'fresh');
930
+
931
+ class UserService {
932
+ @Cache(cacheName, { providerName: 'disabled-versioned' })
933
+ async getUser(): Promise<string> {
934
+ return businessMethod();
935
+ }
936
+ }
937
+
938
+ await expect(new UserService().getUser()).resolves.toBe('fresh');
939
+ expect(businessMethod).toHaveBeenCalledTimes(1);
940
+ expect(provider.values.get(cacheKey)).toEqual({ value: 'fresh' });
941
+ });
942
+
943
+ it('legacy error 条目始终旁路,业务失败后按当前策略写入新 envelope', async () => {
944
+ const provider = createProvider();
945
+ const cacheName = 'legacy-error-entry';
946
+ const cacheKey = KeyBuilder.build(cacheName, []);
947
+ provider.values.set(cacheKey, { error: new Error('legacy') });
948
+ CacheProviderRegistry.register('legacy-error', provider.provider);
949
+ const businessError = new Error('current business failure');
950
+ let callCount = 0;
951
+
952
+ class UserService {
953
+ @Cache(cacheName, { providerName: 'legacy-error', errorCache: { ttl: 5 } })
954
+ async getUser(): Promise<never> {
955
+ callCount += 1;
956
+ throw businessError;
957
+ }
958
+ }
959
+
960
+ const service = new UserService();
961
+ await expect(service.getUser()).rejects.toBe(businessError);
962
+ expect(provider.values.get(cacheKey)).toEqual(
963
+ currentErrorEntry({ type: 'error', name: 'Error', message: 'current business failure' })
964
+ );
965
+ const cachedError = await captureRejection(service.getUser());
966
+ expect((cachedError as Error).message).toBe('current business failure');
967
+ expect(callCount).toBe(1);
968
+ });
969
+ });
970
+
971
+ describe('异常路径 pending 请求合并', () => {
972
+ const pendingCases: ReadonlyArray<{
973
+ readonly label: string;
974
+ readonly error: unknown;
975
+ readonly options: CacheOptions;
976
+ }> = [
977
+ { label: 'disabled', error: new Error('disabled'), options: {} },
978
+ {
979
+ label: 'predicate-rejected',
980
+ error: new Error('predicate rejected'),
981
+ options: { errorCache: { ttl: 5, shouldCache: (): boolean => false } },
982
+ },
983
+ { label: 'encode-failed', error: (): void => undefined, options: { errorCache: { ttl: 5 } } },
984
+ ];
985
+
986
+ it.each(pendingCases)('$label 时并发 rejection 复用同一 Promise,settled 后允许重试', async (testCase) => {
987
+ const provider = createProvider();
988
+ const providerName = `pending-${testCase.label}`;
989
+ const cacheName = `pending-${testCase.label}-users`;
990
+ CacheProviderRegistry.register(providerName, provider.provider);
991
+ const deferred = createRejectionDeferred();
992
+ const businessMethod = jest.fn(() => deferred.promise);
993
+
994
+ class UserService {
995
+ @Cache(cacheName, { ...testCase.options, providerName })
996
+ getUser(): Promise<never> {
997
+ return businessMethod();
998
+ }
999
+ }
1000
+
1001
+ const service = new UserService();
1002
+ const first = service.getUser();
1003
+ const second = service.getUser();
1004
+ expect(second).toBe(first);
1005
+ await Promise.resolve();
1006
+ expect(businessMethod).toHaveBeenCalledTimes(1);
1007
+
1008
+ deferred.reject(testCase.error);
1009
+ expect(await captureRejection(first)).toBe(testCase.error);
1010
+ expect(await captureRejection(second)).toBe(testCase.error);
1011
+ const retry = service.getUser();
1012
+ expect(await captureRejection(retry)).toBe(testCase.error);
1013
+ expect(businessMethod).toHaveBeenCalledTimes(2);
1014
+ expect(provider.get).toHaveBeenCalledTimes(2);
1015
+ expect(provider.set).not.toHaveBeenCalled();
1016
+ });
1017
+ });