@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,362 +1,398 @@
1
- jest.mock('@jintianxiayu/logger', () => ({
2
- LoggerFactory: {
3
- getLogger: jest.fn(),
4
- },
5
- }));
6
-
7
- import { LoggerFactory, type LoggerInterface } from '@jintianxiayu/logger';
8
- import type { CacheProvider } from '../src/core/cache-provider';
9
- import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
10
- import { KeyBuilder } from '../src/core/key-builder';
11
- import { CacheEvict } from '../src/decorators/cache-evict';
12
-
13
- interface EvictProviderHarness {
14
- readonly provider: CacheProvider;
15
- readonly delete: jest.Mock<unknown, [string]>;
16
- readonly deleteByPattern: jest.Mock<unknown, [string]>;
17
- }
18
-
19
- interface CacheLogMetadata {
20
- readonly event: string;
21
- readonly cacheName: string;
22
- readonly methodName: string;
23
- readonly providerName: string;
24
- readonly scope?: string;
25
- readonly reason?: string;
26
- readonly operation?: string;
27
- readonly error?: unknown;
28
- }
29
-
30
- const mockLogger: jest.Mocked<LoggerInterface> = {
31
- debug: jest.fn(),
32
- info: jest.fn(),
33
- warn: jest.fn(),
34
- error: jest.fn(),
35
- };
36
- const mockGetLogger = jest.mocked(LoggerFactory.getLogger);
37
-
38
- /** 创建可观察单 key 与全量删除调用的测试 Provider。 */
39
- function createProvider(): EvictProviderHarness {
40
- const deleteEntry = jest.fn<unknown, [string]>();
41
- const deleteByPattern = jest.fn<unknown, [string]>();
42
- const provider: CacheProvider = {
43
- get: <Value>(_key: string): Value | undefined => undefined,
44
- set: <Value>(_key: string, _value: Value, _ttl?: number): void => {
45
- return;
46
- },
47
- delete: (key: string): void | Promise<void> => deleteEntry(key) as void | Promise<void>,
48
- clear: (): void => {
49
- return;
50
- },
51
- deleteByPattern: (pattern: string): void | Promise<void> => deleteByPattern(pattern) as void | Promise<void>,
52
- };
53
- return { provider, delete: deleteEntry, deleteByPattern };
54
- }
55
-
56
- function loggedMetadata(method: jest.Mock): CacheLogMetadata[] {
57
- return method.mock.calls.map((call) => call[1] as CacheLogMetadata);
58
- }
59
-
60
- function events(method: jest.Mock): string[] {
61
- return loggedMetadata(method).map(({ event }) => event);
62
- }
63
-
64
- beforeEach(() => {
65
- jest.clearAllMocks();
66
- mockGetLogger.mockReturnValue(mockLogger);
67
- CacheProviderRegistry.clear();
68
- });
69
-
70
- afterEach(() => {
71
- jest.restoreAllMocks();
72
- CacheProviderRegistry.clear();
73
- });
74
-
75
- it('cache-operation-logging/E03 业务失败记录 skipped 且完全跳过淘汰准备', async () => {
76
- const provider = createProvider();
77
- CacheProviderRegistry.register('skipped-provider', provider.provider);
78
- const registryGet = jest.spyOn(CacheProviderRegistry, 'get');
79
- const resolver = jest.fn(() => 'unused-key');
80
- const businessError = new Error('business update failed');
81
-
82
- class UserService {
83
- @CacheEvict('skipped-users', { providerName: 'skipped-provider', key: resolver })
84
- updateUser(): never {
85
- throw businessError;
86
- }
87
- }
88
-
89
- await expect(new UserService().updateUser()).rejects.toBe(businessError);
90
- expect(registryGet).not.toHaveBeenCalled();
91
- expect(resolver).not.toHaveBeenCalled();
92
- expect(provider.delete).not.toHaveBeenCalled();
93
- expect(provider.deleteByPattern).not.toHaveBeenCalled();
94
- expect(loggedMetadata(mockLogger.warn)).toEqual([
95
- {
96
- event: 'cache.evict_skipped',
97
- cacheName: 'skipped-users',
98
- methodName: 'updateUser',
99
- providerName: 'skipped-provider',
100
- reason: 'business_error',
101
- },
102
- ]);
103
- expect(mockLogger.debug).not.toHaveBeenCalled();
104
- expect(mockLogger.error).not.toHaveBeenCalled();
105
- });
106
-
107
- it('cache-operation-logging/K02 单 key resolver 失败后记录 fallback 并只删除默认 key', async () => {
108
- const provider = createProvider();
109
- CacheProviderRegistry.register('fallback-evict-provider', provider.provider);
110
- const resolver = jest.fn(() => {
111
- throw new Error('resolver failed with secret');
112
- });
113
- const businessResult = { updated: true };
114
-
115
- class UserService {
116
- @CacheEvict('fallback-evict-users', { providerName: 'fallback-evict-provider', key: resolver })
117
- updateUser(id: number): { updated: boolean } {
118
- return { ...businessResult, id };
119
- }
120
- }
121
-
122
- const result = await new UserService().updateUser(13);
123
- expect(result).toEqual({ ...businessResult, id: 13 });
124
- expect(resolver).toHaveBeenCalledTimes(1);
125
- expect(provider.delete).toHaveBeenCalledTimes(1);
126
- expect(provider.delete).toHaveBeenCalledWith(KeyBuilder.build('fallback-evict-users', [13]));
127
- expect(provider.deleteByPattern).not.toHaveBeenCalled();
128
- expect(loggedMetadata(mockLogger.warn)).toEqual([
129
- {
130
- event: 'cache.key_fallback',
131
- cacheName: 'fallback-evict-users',
132
- methodName: 'updateUser',
133
- providerName: 'fallback-evict-provider',
134
- reason: 'resolver_error',
135
- },
136
- ]);
137
- expect(JSON.stringify(loggedMetadata(mockLogger.warn))).not.toContain('resolver failed with secret');
138
- });
139
-
140
- it('cache-operation-logging/K03 allEntries 忽略 resolver 且不记录 fallback', async () => {
141
- const provider = createProvider();
142
- CacheProviderRegistry.register('all-entries-provider', provider.provider);
143
- const resolver = jest.fn(() => {
144
- throw new Error('must not run');
145
- });
146
-
147
- class UserService {
148
- @CacheEvict('all-users', {
149
- providerName: 'all-entries-provider',
150
- allEntries: true,
151
- key: resolver,
152
- })
153
- clearUsers(): string {
154
- return 'cleared';
155
- }
156
- }
157
-
158
- await expect(new UserService().clearUsers()).resolves.toBe('cleared');
159
- expect(resolver).not.toHaveBeenCalled();
160
- expect(provider.delete).not.toHaveBeenCalled();
161
- expect(provider.deleteByPattern).toHaveBeenCalledTimes(1);
162
- expect(provider.deleteByPattern).toHaveBeenCalledWith('all-users*');
163
- expect(events(mockLogger.warn)).not.toContain('cache.key_fallback');
164
- });
165
-
166
- it('cache-operation-logging/E01 单 key 删除返回控制权后记录 dispatched', async () => {
167
- const provider = createProvider();
168
- CacheProviderRegistry.register('single-evict-provider', provider.provider);
169
- const businessResult = { deleted: true };
170
-
171
- class UserService {
172
- @CacheEvict('single-users', { providerName: 'single-evict-provider', key: 'user-14' })
173
- deleteUser(): { deleted: boolean } {
174
- return businessResult;
175
- }
176
- }
177
-
178
- await expect(new UserService().deleteUser()).resolves.toBe(businessResult);
179
- expect(provider.delete).toHaveBeenCalledTimes(1);
180
- expect(provider.deleteByPattern).not.toHaveBeenCalled();
181
- expect(loggedMetadata(mockLogger.debug)).toEqual([
182
- {
183
- event: 'cache.evict_dispatched',
184
- cacheName: 'single-users',
185
- methodName: 'deleteUser',
186
- providerName: 'single-evict-provider',
187
- scope: 'key',
188
- },
189
- ]);
190
- });
191
-
192
- it('cache-operation-logging/E02 allEntries 等待删除完成后才记录 completed', async () => {
193
- let resolveEviction: (() => void) | undefined;
194
- const evictionPromise = new Promise<void>((resolve) => {
195
- resolveEviction = resolve;
196
- });
197
- const provider = createProvider();
198
- provider.deleteByPattern.mockReturnValue(evictionPromise);
199
- CacheProviderRegistry.register('awaited-evict-provider', provider.provider);
200
-
201
- class UserService {
202
- @CacheEvict('awaited-users', { providerName: 'awaited-evict-provider', allEntries: true })
203
- clearUsers(): string {
204
- return 'cleared';
205
- }
206
- }
207
-
208
- const operation = new UserService().clearUsers() as unknown as Promise<string>;
209
- await Promise.resolve();
210
- expect(provider.deleteByPattern).toHaveBeenCalledWith('awaited-users*');
211
- expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
212
-
213
- if (!resolveEviction) {
214
- throw new Error('Eviction resolver was not initialized');
215
- }
216
- resolveEviction();
217
- await expect(operation).resolves.toBe('cleared');
218
- expect(loggedMetadata(mockLogger.debug)).toEqual([
219
- {
220
- event: 'cache.evict_completed',
221
- cacheName: 'awaited-users',
222
- methodName: 'clearUsers',
223
- providerName: 'awaited-evict-provider',
224
- scope: 'allEntries',
225
- },
226
- ]);
227
- });
228
-
229
- it('cache-operation-logging/E04 重复淘汰每次只执行一次实际删除和一次日志', async () => {
230
- const provider = createProvider();
231
- CacheProviderRegistry.register('repeat-evict-provider', provider.provider);
232
-
233
- class UserService {
234
- @CacheEvict('repeat-evict-users', { providerName: 'repeat-evict-provider', key: 'shared' })
235
- deleteUser(): boolean {
236
- return true;
237
- }
238
- }
239
-
240
- const service = new UserService();
241
- await service.deleteUser();
242
- await service.deleteUser();
243
- expect(provider.delete).toHaveBeenCalledTimes(2);
244
- expect(events(mockLogger.debug)).toEqual(['cache.evict_dispatched', 'cache.evict_dispatched']);
245
- });
246
-
247
- it('cache-operation-logging/F03 allEntries 删除失败记录同一错误且不报告 completed', async () => {
248
- const evictionError = new Error('redis scan failed');
249
- const provider = createProvider();
250
- provider.deleteByPattern.mockRejectedValue(evictionError);
251
- CacheProviderRegistry.register('failed-all-evict-provider', provider.provider);
252
-
253
- class UserService {
254
- @CacheEvict('failed-all-users', { providerName: 'failed-all-evict-provider', allEntries: true })
255
- clearUsers(): boolean {
256
- return true;
257
- }
258
- }
259
-
260
- await expect(new UserService().clearUsers()).rejects.toBe(evictionError);
261
- expect(provider.deleteByPattern).toHaveBeenCalledTimes(1);
262
- expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
263
- expect(loggedMetadata(mockLogger.error)).toEqual([
264
- {
265
- event: 'cache.operation_failed',
266
- cacheName: 'failed-all-users',
267
- methodName: 'clearUsers',
268
- providerName: 'failed-all-evict-provider',
269
- operation: 'evict',
270
- error: evictionError,
271
- },
272
- ]);
273
- });
274
-
275
- it('cache-operation-logging/F05 异步单 key 删除拒绝不会被等待或消费', async () => {
276
- let rejectEviction: ((reason?: unknown) => void) | undefined;
277
- const evictionError = new Error('async delete failed');
278
- const evictionPromise = new Promise<void>((_resolve, reject) => {
279
- rejectEviction = reject;
280
- });
281
- const observedEvictionFailure = evictionPromise.catch((error: unknown) => error);
282
- const thenSpy = jest.spyOn(evictionPromise, 'then');
283
- const provider = createProvider();
284
- provider.delete.mockReturnValue(evictionPromise);
285
- CacheProviderRegistry.register('async-delete-provider', provider.provider);
286
-
287
- class UserService {
288
- @CacheEvict('async-delete-users', { providerName: 'async-delete-provider', key: 'user-15' })
289
- deleteUser(): number {
290
- return 15;
291
- }
292
- }
293
-
294
- await expect(new UserService().deleteUser()).resolves.toBe(15);
295
- expect(provider.delete).toHaveBeenCalledTimes(1);
296
- expect(thenSpy).not.toHaveBeenCalled();
297
- expect(events(mockLogger.debug)).toEqual(['cache.evict_dispatched']);
298
- expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
299
-
300
- if (!rejectEviction) {
301
- throw new Error('Eviction rejecter was not initialized');
302
- }
303
- rejectEviction(evictionError);
304
- await expect(observedEvictionFailure).resolves.toBe(evictionError);
305
- });
306
-
307
- it('淘汰 Provider 解析失败记录 provider_resolution 且不删除', async () => {
308
- const registryError = new Error('evict provider missing');
309
- jest.spyOn(CacheProviderRegistry, 'get').mockImplementation(() => {
310
- throw registryError;
311
- });
312
- const businessMethod = jest.fn(() => 'updated');
313
-
314
- class UserService {
315
- @CacheEvict('missing-evict-users', { providerName: 'missing-evict-provider' })
316
- updateUser(): string {
317
- return businessMethod();
318
- }
319
- }
320
-
321
- await expect(new UserService().updateUser()).rejects.toBe(registryError);
322
- expect(businessMethod).toHaveBeenCalledTimes(1);
323
- expect(loggedMetadata(mockLogger.error)).toEqual([
324
- {
325
- event: 'cache.operation_failed',
326
- cacheName: 'missing-evict-users',
327
- methodName: 'updateUser',
328
- providerName: 'missing-evict-provider',
329
- operation: 'provider_resolution',
330
- error: registryError,
331
- },
332
- ]);
333
- expect(mockLogger.debug).not.toHaveBeenCalled();
334
- });
335
-
336
- it('同步单 key 删除失败记录 evict operation 且传播同一错误', async () => {
337
- const evictionError = new Error('synchronous delete failed');
338
- const provider = createProvider();
339
- provider.delete.mockImplementation(() => {
340
- throw evictionError;
341
- });
342
- CacheProviderRegistry.register('sync-delete-provider', provider.provider);
343
-
344
- class UserService {
345
- @CacheEvict('sync-delete-users', { providerName: 'sync-delete-provider' })
346
- deleteUser(id: number): number {
347
- return id;
348
- }
349
- }
350
-
351
- await expect(new UserService().deleteUser(16)).rejects.toBe(evictionError);
352
- expect(provider.delete).toHaveBeenCalledTimes(1);
353
- expect(events(mockLogger.debug)).not.toContain('cache.evict_dispatched');
354
- expect(loggedMetadata(mockLogger.error)).toContainEqual({
355
- event: 'cache.operation_failed',
356
- cacheName: 'sync-delete-users',
357
- methodName: 'deleteUser',
358
- providerName: 'sync-delete-provider',
359
- operation: 'evict',
360
- error: evictionError,
361
- });
362
- });
1
+ jest.mock('@jintianxiayu/logger', () => ({
2
+ LoggerFactory: {
3
+ getLogger: jest.fn(),
4
+ },
5
+ }));
6
+
7
+ import { LoggerFactory, type LoggerInterface } from '@jintianxiayu/logger';
8
+ import type { CacheProvider } from '../src/core/cache-provider';
9
+ import { CacheProviderRegistry } from '../src/core/cache-provider-registry';
10
+ import { KeyBuilder } from '../src/core/key-builder';
11
+ import { CacheEvict } from '../src/decorators/cache-evict';
12
+
13
+ interface EvictProviderHarness {
14
+ readonly provider: CacheProvider;
15
+ readonly delete: jest.Mock<unknown, [string]>;
16
+ readonly deleteByPattern: jest.Mock<unknown, [string]>;
17
+ }
18
+
19
+ interface CacheLogMetadata {
20
+ readonly event: string;
21
+ readonly cacheName: string;
22
+ readonly methodName: string;
23
+ readonly providerName: string;
24
+ readonly scope?: string;
25
+ readonly reason?: string;
26
+ readonly operation?: string;
27
+ readonly error?: unknown;
28
+ }
29
+
30
+ const mockLogger: jest.Mocked<LoggerInterface> = {
31
+ debug: jest.fn(),
32
+ info: jest.fn(),
33
+ warn: jest.fn(),
34
+ error: jest.fn(),
35
+ };
36
+ const mockGetLogger = jest.mocked(LoggerFactory.getLogger);
37
+
38
+ /** 创建可观察单 key 与全量删除调用的测试 Provider。 */
39
+ function createProvider(): EvictProviderHarness {
40
+ const deleteEntry = jest.fn<unknown, [string]>();
41
+ const deleteByPattern = jest.fn<unknown, [string]>();
42
+ const provider: CacheProvider = {
43
+ get: <Value>(_key: string): Value | undefined => undefined,
44
+ set: <Value>(_key: string, _value: Value, _ttl?: number): void => {
45
+ return;
46
+ },
47
+ delete: (key: string): void | Promise<void> => deleteEntry(key) as void | Promise<void>,
48
+ clear: (): void => {
49
+ return;
50
+ },
51
+ deleteByPattern: (pattern: string): void | Promise<void> => deleteByPattern(pattern) as void | Promise<void>,
52
+ };
53
+ return { provider, delete: deleteEntry, deleteByPattern };
54
+ }
55
+
56
+ function loggedMetadata(method: jest.Mock): CacheLogMetadata[] {
57
+ return method.mock.calls.map((call) => call[1] as CacheLogMetadata);
58
+ }
59
+
60
+ function events(method: jest.Mock): string[] {
61
+ return loggedMetadata(method).map(({ event }) => event);
62
+ }
63
+
64
+ beforeEach(() => {
65
+ jest.clearAllMocks();
66
+ mockGetLogger.mockReturnValue(mockLogger);
67
+ CacheProviderRegistry.clear();
68
+ });
69
+
70
+ afterEach(() => {
71
+ jest.restoreAllMocks();
72
+ CacheProviderRegistry.clear();
73
+ });
74
+
75
+ it('cache-operation-logging/E03 业务失败记录 skipped 且完全跳过淘汰准备', async () => {
76
+ const provider = createProvider();
77
+ CacheProviderRegistry.register('skipped-provider', provider.provider);
78
+ const registryGet = jest.spyOn(CacheProviderRegistry, 'get');
79
+ const resolver = jest.fn(() => 'unused-key');
80
+ const businessError = new Error('business update failed');
81
+
82
+ class UserService {
83
+ @CacheEvict('skipped-users', { providerName: 'skipped-provider', key: resolver })
84
+ updateUser(): never {
85
+ throw businessError;
86
+ }
87
+ }
88
+
89
+ await expect(new UserService().updateUser()).rejects.toBe(businessError);
90
+ expect(registryGet).not.toHaveBeenCalled();
91
+ expect(resolver).not.toHaveBeenCalled();
92
+ expect(provider.delete).not.toHaveBeenCalled();
93
+ expect(provider.deleteByPattern).not.toHaveBeenCalled();
94
+ expect(loggedMetadata(mockLogger.warn)).toEqual([
95
+ {
96
+ event: 'cache.evict_skipped',
97
+ cacheName: 'skipped-users',
98
+ methodName: 'updateUser',
99
+ providerName: 'skipped-provider',
100
+ reason: 'business_error',
101
+ },
102
+ ]);
103
+ expect(mockLogger.debug).not.toHaveBeenCalled();
104
+ expect(mockLogger.error).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it('cache-operation-logging/K02 单 key resolver 失败后记录 fallback 并只删除默认 key', async () => {
108
+ const provider = createProvider();
109
+ CacheProviderRegistry.register('fallback-evict-provider', provider.provider);
110
+ const resolver = jest.fn(() => {
111
+ throw new Error('resolver failed with secret');
112
+ });
113
+ const businessResult = { updated: true };
114
+
115
+ class UserService {
116
+ @CacheEvict('fallback-evict-users', { providerName: 'fallback-evict-provider', key: resolver })
117
+ updateUser(id: number): { updated: boolean } {
118
+ return { ...businessResult, id };
119
+ }
120
+ }
121
+
122
+ const result = await new UserService().updateUser(13);
123
+ expect(result).toEqual({ ...businessResult, id: 13 });
124
+ expect(resolver).toHaveBeenCalledTimes(1);
125
+ expect(provider.delete).toHaveBeenCalledTimes(1);
126
+ expect(provider.delete).toHaveBeenCalledWith(KeyBuilder.build('fallback-evict-users', [13]));
127
+ expect(provider.deleteByPattern).not.toHaveBeenCalled();
128
+ expect(loggedMetadata(mockLogger.warn)).toEqual([
129
+ {
130
+ event: 'cache.key_fallback',
131
+ cacheName: 'fallback-evict-users',
132
+ methodName: 'updateUser',
133
+ providerName: 'fallback-evict-provider',
134
+ reason: 'resolver_error',
135
+ },
136
+ ]);
137
+ expect(JSON.stringify(loggedMetadata(mockLogger.warn))).not.toContain('resolver failed with secret');
138
+ });
139
+
140
+ it('cache-operation-logging/K03 allEntries 忽略 resolver 且不记录 fallback', async () => {
141
+ const provider = createProvider();
142
+ CacheProviderRegistry.register('all-entries-provider', provider.provider);
143
+ const resolver = jest.fn(() => {
144
+ throw new Error('must not run');
145
+ });
146
+
147
+ class UserService {
148
+ @CacheEvict('all-users', {
149
+ providerName: 'all-entries-provider',
150
+ allEntries: true,
151
+ key: resolver,
152
+ })
153
+ clearUsers(): string {
154
+ return 'cleared';
155
+ }
156
+ }
157
+
158
+ await expect(new UserService().clearUsers()).resolves.toBe('cleared');
159
+ expect(resolver).not.toHaveBeenCalled();
160
+ expect(provider.delete).not.toHaveBeenCalled();
161
+ expect(provider.deleteByPattern).toHaveBeenCalledTimes(1);
162
+ expect(provider.deleteByPattern).toHaveBeenCalledWith('all-users*');
163
+ expect(events(mockLogger.warn)).not.toContain('cache.key_fallback');
164
+ });
165
+
166
+ it('cache-operation-logging/E01 单 key 删除返回控制权后记录 dispatched', async () => {
167
+ const provider = createProvider();
168
+ CacheProviderRegistry.register('single-evict-provider', provider.provider);
169
+ const businessResult = { deleted: true };
170
+
171
+ class UserService {
172
+ @CacheEvict('single-users', { providerName: 'single-evict-provider', key: 'user-14' })
173
+ deleteUser(): { deleted: boolean } {
174
+ return businessResult;
175
+ }
176
+ }
177
+
178
+ await expect(new UserService().deleteUser()).resolves.toBe(businessResult);
179
+ expect(provider.delete).toHaveBeenCalledTimes(1);
180
+ expect(provider.deleteByPattern).not.toHaveBeenCalled();
181
+ expect(loggedMetadata(mockLogger.debug)).toEqual([
182
+ {
183
+ event: 'cache.evict_dispatched',
184
+ cacheName: 'single-users',
185
+ methodName: 'deleteUser',
186
+ providerName: 'single-evict-provider',
187
+ scope: 'key',
188
+ },
189
+ ]);
190
+ });
191
+
192
+ it('cache-operation-logging/E02 allEntries 等待删除完成后才记录 completed', async () => {
193
+ let resolveEviction: (() => void) | undefined;
194
+ const evictionPromise = new Promise<void>((resolve) => {
195
+ resolveEviction = resolve;
196
+ });
197
+ const provider = createProvider();
198
+ provider.deleteByPattern.mockReturnValue(evictionPromise);
199
+ CacheProviderRegistry.register('awaited-evict-provider', provider.provider);
200
+
201
+ class UserService {
202
+ @CacheEvict('awaited-users', { providerName: 'awaited-evict-provider', allEntries: true })
203
+ clearUsers(): string {
204
+ return 'cleared';
205
+ }
206
+ }
207
+
208
+ const operation = new UserService().clearUsers() as unknown as Promise<string>;
209
+ await Promise.resolve();
210
+ expect(provider.deleteByPattern).toHaveBeenCalledWith('awaited-users*');
211
+ expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
212
+
213
+ if (!resolveEviction) {
214
+ throw new Error('Eviction resolver was not initialized');
215
+ }
216
+ resolveEviction();
217
+ await expect(operation).resolves.toBe('cleared');
218
+ expect(loggedMetadata(mockLogger.debug)).toEqual([
219
+ {
220
+ event: 'cache.evict_completed',
221
+ cacheName: 'awaited-users',
222
+ methodName: 'clearUsers',
223
+ providerName: 'awaited-evict-provider',
224
+ scope: 'allEntries',
225
+ },
226
+ ]);
227
+ });
228
+
229
+ it('cache-operation-logging/E04 重复淘汰每次只执行一次实际删除和一次日志', async () => {
230
+ const provider = createProvider();
231
+ CacheProviderRegistry.register('repeat-evict-provider', provider.provider);
232
+
233
+ class UserService {
234
+ @CacheEvict('repeat-evict-users', { providerName: 'repeat-evict-provider', key: 'shared' })
235
+ deleteUser(): boolean {
236
+ return true;
237
+ }
238
+ }
239
+
240
+ const service = new UserService();
241
+ await service.deleteUser();
242
+ await service.deleteUser();
243
+ expect(provider.delete).toHaveBeenCalledTimes(2);
244
+ expect(events(mockLogger.debug)).toEqual(['cache.evict_dispatched', 'cache.evict_dispatched']);
245
+ });
246
+
247
+ it('cache-operation-logging/F03 allEntries 删除失败记录同一错误且保留业务结果', async () => {
248
+ const evictionError = new Error('redis scan failed');
249
+ const provider = createProvider();
250
+ provider.deleteByPattern.mockRejectedValue(evictionError);
251
+ CacheProviderRegistry.register('failed-all-evict-provider', provider.provider);
252
+
253
+ class UserService {
254
+ @CacheEvict('failed-all-users', { providerName: 'failed-all-evict-provider', allEntries: true })
255
+ clearUsers(): boolean {
256
+ return true;
257
+ }
258
+ }
259
+
260
+ await expect(new UserService().clearUsers()).resolves.toBe(true);
261
+ expect(provider.deleteByPattern).toHaveBeenCalledTimes(1);
262
+ expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
263
+ expect(loggedMetadata(mockLogger.error)).toEqual([
264
+ {
265
+ event: 'cache.operation_failed',
266
+ cacheName: 'failed-all-users',
267
+ methodName: 'clearUsers',
268
+ providerName: 'failed-all-evict-provider',
269
+ operation: 'evict',
270
+ error: evictionError,
271
+ },
272
+ ]);
273
+ });
274
+
275
+ it('cache-operation-logging/F03 allEntries 同步删除失败保留业务结果且不报告 completed', async () => {
276
+ const evictionError = new Error('synchronous scan failed');
277
+ const provider = createProvider();
278
+ provider.deleteByPattern.mockImplementation(() => {
279
+ throw evictionError;
280
+ });
281
+ CacheProviderRegistry.register('sync-failed-all-provider', provider.provider);
282
+
283
+ class UserService {
284
+ @CacheEvict('sync-failed-all-users', { providerName: 'sync-failed-all-provider', allEntries: true })
285
+ clearUsers(): string {
286
+ return 'business-result';
287
+ }
288
+ }
289
+
290
+ await expect(new UserService().clearUsers()).resolves.toBe('business-result');
291
+ expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
292
+ expect(loggedMetadata(mockLogger.error)).toContainEqual({
293
+ event: 'cache.operation_failed',
294
+ cacheName: 'sync-failed-all-users',
295
+ methodName: 'clearUsers',
296
+ providerName: 'sync-failed-all-provider',
297
+ operation: 'evict',
298
+ error: evictionError,
299
+ });
300
+ });
301
+
302
+ it('cache-operation-logging/F05 异步单 key 删除拒绝被观察但不改变业务结果', async () => {
303
+ let rejectEviction: ((reason?: unknown) => void) | undefined;
304
+ const evictionError = new Error('async delete failed');
305
+ const evictionPromise = new Promise<void>((_resolve, reject) => {
306
+ rejectEviction = reject;
307
+ });
308
+ const thenSpy = jest.spyOn(evictionPromise, 'then');
309
+ const provider = createProvider();
310
+ provider.delete.mockReturnValue(evictionPromise);
311
+ CacheProviderRegistry.register('async-delete-provider', provider.provider);
312
+
313
+ class UserService {
314
+ @CacheEvict('async-delete-users', { providerName: 'async-delete-provider', key: 'user-15' })
315
+ deleteUser(): number {
316
+ return 15;
317
+ }
318
+ }
319
+
320
+ await expect(new UserService().deleteUser()).resolves.toBe(15);
321
+ expect(provider.delete).toHaveBeenCalledTimes(1);
322
+ expect(thenSpy).toHaveBeenCalledTimes(1);
323
+ expect(events(mockLogger.debug)).toEqual(['cache.evict_dispatched']);
324
+ expect(events(mockLogger.debug)).not.toContain('cache.evict_completed');
325
+
326
+ if (!rejectEviction) {
327
+ throw new Error('Eviction rejecter was not initialized');
328
+ }
329
+ rejectEviction(evictionError);
330
+ await Promise.resolve();
331
+ expect(loggedMetadata(mockLogger.error)).toContainEqual({
332
+ event: 'cache.operation_failed',
333
+ cacheName: 'async-delete-users',
334
+ methodName: 'deleteUser',
335
+ providerName: 'async-delete-provider',
336
+ operation: 'evict',
337
+ error: evictionError,
338
+ });
339
+ });
340
+
341
+ it('淘汰 Provider 解析失败记录 provider_resolution 且保留业务结果', async () => {
342
+ const registryError = new Error('evict provider missing');
343
+ jest.spyOn(CacheProviderRegistry, 'get').mockImplementation(() => {
344
+ throw registryError;
345
+ });
346
+ const businessMethod = jest.fn(() => 'updated');
347
+ const resolver = jest.fn(() => 'unused-key');
348
+
349
+ class UserService {
350
+ @CacheEvict('missing-evict-users', { providerName: 'missing-evict-provider', key: resolver })
351
+ updateUser(): string {
352
+ return businessMethod();
353
+ }
354
+ }
355
+
356
+ await expect(new UserService().updateUser()).resolves.toBe('updated');
357
+ expect(businessMethod).toHaveBeenCalledTimes(1);
358
+ expect(resolver).not.toHaveBeenCalled();
359
+ expect(loggedMetadata(mockLogger.error)).toEqual([
360
+ {
361
+ event: 'cache.operation_failed',
362
+ cacheName: 'missing-evict-users',
363
+ methodName: 'updateUser',
364
+ providerName: 'missing-evict-provider',
365
+ operation: 'provider_resolution',
366
+ error: registryError,
367
+ },
368
+ ]);
369
+ expect(mockLogger.debug).not.toHaveBeenCalled();
370
+ });
371
+
372
+ it('同步单 key 删除失败记录 evict operation 且保留业务结果', async () => {
373
+ const evictionError = new Error('synchronous delete failed');
374
+ const provider = createProvider();
375
+ provider.delete.mockImplementation(() => {
376
+ throw evictionError;
377
+ });
378
+ CacheProviderRegistry.register('sync-delete-provider', provider.provider);
379
+
380
+ class UserService {
381
+ @CacheEvict('sync-delete-users', { providerName: 'sync-delete-provider' })
382
+ deleteUser(id: number): number {
383
+ return id;
384
+ }
385
+ }
386
+
387
+ await expect(new UserService().deleteUser(16)).resolves.toBe(16);
388
+ expect(provider.delete).toHaveBeenCalledTimes(1);
389
+ expect(events(mockLogger.debug)).not.toContain('cache.evict_dispatched');
390
+ expect(loggedMetadata(mockLogger.error)).toContainEqual({
391
+ event: 'cache.operation_failed',
392
+ cacheName: 'sync-delete-users',
393
+ methodName: 'deleteUser',
394
+ providerName: 'sync-delete-provider',
395
+ operation: 'evict',
396
+ error: evictionError,
397
+ });
398
+ });