@jintianxiayu/cache-decorator 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -11
- package/README.md +364 -270
- package/dist/core/cache-error.d.ts +75 -0
- package/dist/core/cache-error.d.ts.map +1 -0
- package/dist/core/cache-error.js +168 -0
- package/dist/core/cache-error.js.map +1 -0
- package/dist/core/cache-logger.d.ts +3 -2
- package/dist/core/cache-logger.d.ts.map +1 -1
- package/dist/core/cache-logger.js +2 -0
- package/dist/core/cache-logger.js.map +1 -1
- package/dist/decorators/cache-evict.d.ts.map +1 -1
- package/dist/decorators/cache-evict.js +15 -10
- package/dist/decorators/cache-evict.js.map +1 -1
- package/dist/decorators/cache.d.ts +6 -0
- package/dist/decorators/cache.d.ts.map +1 -1
- package/dist/decorators/cache.js +113 -36
- package/dist/decorators/cache.js.map +1 -1
- package/jest.config.js +11 -11
- package/package.json +1 -1
- package/src/adapters/ioredis-cache-client.ts +89 -89
- package/src/adapters/node-redis-cache-client.ts +95 -95
- package/src/adapters/redis-key-prefix.ts +38 -38
- package/src/core/cache-error.ts +233 -0
- package/src/core/cache-logger.ts +116 -105
- package/src/core/key-builder.ts +33 -33
- package/src/core/native-cache.ts +55 -55
- package/src/core/pending-cache.ts +29 -29
- package/src/core/redis-cache-client.ts +160 -160
- package/src/core/redis-cache.ts +104 -104
- package/src/decorators/cache-evict.ts +137 -129
- package/src/decorators/cache.ts +323 -203
- package/src/index.ts +11 -11
- package/test/cache-evict-logging.test.ts +398 -362
- package/test/cache-logger.integration.test.ts +159 -129
- package/test/cache-logger.test.ts +156 -153
- package/test/cache-logging.test.ts +929 -544
- package/test/cache.test.ts +1017 -255
- package/test/fixtures/cache-logger-child.mjs +108 -108
- package/test/fixtures/cache-provider-failure-child.mjs +70 -0
- package/test/helpers/legacy-redis-cache.ts +41 -22
- package/test/helpers/package-consumer.ts +231 -231
- package/test/helpers/redis-fixture.ts +142 -142
- package/test/ioredis-cache-client.test.ts +143 -143
- package/test/legacy-redis-cache.test.ts +51 -0
- package/test/native-cache.test.ts +77 -77
- package/test/node-redis-cache-client.test.ts +149 -149
- package/test/pending-cache.test.ts +69 -69
- package/test/redis-cache-client-lifecycle.test.ts +112 -112
- package/test/redis-cache-client-types.test.ts +184 -184
- package/test/redis-cache-client.integration.test.ts +355 -327
- package/test/redis-cache-decorator.test.ts +601 -201
- package/test/redis-cache-provider.test.ts +269 -269
- package/test/type-contract/contract.ts +119 -64
- package/test/type-contract/tsconfig.json +12 -12
- package/tsconfig.json +8 -8
|
@@ -1,269 +1,269 @@
|
|
|
1
|
-
import { RedisCacheClient } from '../src/core/redis-cache-client';
|
|
2
|
-
import { RedisCacheProvider } from '../src/core/redis-cache';
|
|
3
|
-
|
|
4
|
-
function createClient(): jest.Mocked<RedisCacheClient> {
|
|
5
|
-
const client = {
|
|
6
|
-
get: jest.fn<Promise<string | null>, Parameters<RedisCacheClient['get']>>(),
|
|
7
|
-
set: jest.fn<Promise<void>, Parameters<RedisCacheClient['set']>>(),
|
|
8
|
-
deleteMany: jest.fn<Promise<void>, Parameters<RedisCacheClient['deleteMany']>>(),
|
|
9
|
-
scan: jest.fn<ReturnType<RedisCacheClient['scan']>, Parameters<RedisCacheClient['scan']>>(),
|
|
10
|
-
flushDatabase: jest.fn<Promise<void>, Parameters<RedisCacheClient['flushDatabase']>>(),
|
|
11
|
-
} satisfies jest.Mocked<RedisCacheClient>;
|
|
12
|
-
client.get.mockResolvedValue(null);
|
|
13
|
-
client.set.mockResolvedValue(undefined);
|
|
14
|
-
client.deleteMany.mockResolvedValue(undefined);
|
|
15
|
-
client.scan.mockResolvedValue({ cursor: '0', keys: [] });
|
|
16
|
-
client.flushDatabase.mockResolvedValue(undefined);
|
|
17
|
-
return client;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function useStringStorage(client: jest.Mocked<RedisCacheClient>): Map<string, string> {
|
|
21
|
-
const values = new Map<string, string>();
|
|
22
|
-
client.set.mockImplementation(({ key, value }): Promise<void> => {
|
|
23
|
-
values.set(key, value);
|
|
24
|
-
return Promise.resolve();
|
|
25
|
-
});
|
|
26
|
-
client.get.mockImplementation((key): Promise<string | null> => Promise.resolve(values.get(key) ?? null));
|
|
27
|
-
return values;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
it('redis-cache-client/V01 不存在的 key 返回 cache miss', async () => {
|
|
31
|
-
const client = createClient();
|
|
32
|
-
const provider = new RedisCacheProvider(client);
|
|
33
|
-
|
|
34
|
-
await expect(provider.get('missing')).resolves.toBeUndefined();
|
|
35
|
-
|
|
36
|
-
expect(client.get).toHaveBeenCalledWith('missing');
|
|
37
|
-
expect(client.set).not.toHaveBeenCalled();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('redis-cache-client/V03 非 JSON 字符串和空字符串保持原值', async () => {
|
|
41
|
-
const client = createClient();
|
|
42
|
-
const values = useStringStorage(client);
|
|
43
|
-
const provider = new RedisCacheProvider(client);
|
|
44
|
-
|
|
45
|
-
await provider.set('plain', 'not-json');
|
|
46
|
-
await provider.set('empty', '');
|
|
47
|
-
|
|
48
|
-
expect(values).toEqual(
|
|
49
|
-
new Map([
|
|
50
|
-
['plain', 'not-json'],
|
|
51
|
-
['empty', ''],
|
|
52
|
-
])
|
|
53
|
-
);
|
|
54
|
-
await expect(provider.get('plain')).resolves.toBe('not-json');
|
|
55
|
-
await expect(provider.get('empty')).resolves.toBe('');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('redis-cache-client/V04 JSON 文本字符串沿用解析行为', async () => {
|
|
59
|
-
const client = createClient();
|
|
60
|
-
const values = useStringStorage(client);
|
|
61
|
-
const provider = new RedisCacheProvider(client);
|
|
62
|
-
const cases: ReadonlyArray<readonly [string, unknown]> = [
|
|
63
|
-
['123', 123],
|
|
64
|
-
['true', true],
|
|
65
|
-
['null', null],
|
|
66
|
-
['[1,2]', [1, 2]],
|
|
67
|
-
['{"status":"open"}', { status: 'open' }],
|
|
68
|
-
];
|
|
69
|
-
|
|
70
|
-
for (const [source, expected] of cases) {
|
|
71
|
-
await provider.set(source, source);
|
|
72
|
-
expect(values.get(source)).toBe(source);
|
|
73
|
-
await expect(provider.get(source)).resolves.toEqual(expected);
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('redis-cache-client/V06 缺省 TTL 与零 TTL 永不过期', async () => {
|
|
78
|
-
const client = createClient();
|
|
79
|
-
const provider = new RedisCacheProvider(client);
|
|
80
|
-
|
|
81
|
-
await provider.set('default-ttl', 'value');
|
|
82
|
-
await provider.set('zero-ttl', 'value', 0);
|
|
83
|
-
|
|
84
|
-
expect(client.set).toHaveBeenNthCalledWith(1, { key: 'default-ttl', value: 'value' });
|
|
85
|
-
expect(client.set).toHaveBeenNthCalledWith(2, { key: 'zero-ttl', value: 'value' });
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('redis-cache-client/V07 序列化失败不写入', async () => {
|
|
89
|
-
const client = createClient();
|
|
90
|
-
const provider = new RedisCacheProvider(client);
|
|
91
|
-
const cyclic: { self?: unknown } = {};
|
|
92
|
-
cyclic.self = cyclic;
|
|
93
|
-
const values: readonly unknown[] = [cyclic, undefined, (): void => undefined, Symbol('cache')];
|
|
94
|
-
|
|
95
|
-
for (const value of values) {
|
|
96
|
-
await expect(provider.set('key', value)).rejects.toThrow(TypeError);
|
|
97
|
-
}
|
|
98
|
-
expect(client.set).not.toHaveBeenCalled();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('redis-cache-client/V08 key 边界不被 Provider 改写', async () => {
|
|
102
|
-
const client = createClient();
|
|
103
|
-
const provider = new RedisCacheProvider(client);
|
|
104
|
-
const keys = ['', '订单:缓存[*]?'];
|
|
105
|
-
client.get.mockResolvedValue('value');
|
|
106
|
-
|
|
107
|
-
for (const key of keys) {
|
|
108
|
-
await provider.set(key, 'value');
|
|
109
|
-
await provider.get(key);
|
|
110
|
-
await provider.delete(key);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
expect(client.set.mock.calls.map(([request]) => request.key)).toEqual(keys);
|
|
114
|
-
expect(client.get.mock.calls.map(([key]) => key)).toEqual(keys);
|
|
115
|
-
expect(client.deleteMany.mock.calls.map(([deletedKeys]) => deletedKeys)).toEqual(keys.map((key) => [key]));
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('redis-cache-client/E01 读取异常不是 cache miss', async () => {
|
|
119
|
-
const client = createClient();
|
|
120
|
-
const provider = new RedisCacheProvider(client);
|
|
121
|
-
const error = new Error('GET unavailable');
|
|
122
|
-
client.get.mockRejectedValue(error);
|
|
123
|
-
|
|
124
|
-
await expect(provider.get('key')).rejects.toBe(error);
|
|
125
|
-
expect(client.set).not.toHaveBeenCalled();
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('redis-cache-client/E02 写入异常传播', async () => {
|
|
129
|
-
const client = createClient();
|
|
130
|
-
const provider = new RedisCacheProvider(client);
|
|
131
|
-
const error = new Error('SET unavailable');
|
|
132
|
-
client.set.mockRejectedValue(error);
|
|
133
|
-
|
|
134
|
-
await expect(provider.set('key', { value: true })).rejects.toBe(error);
|
|
135
|
-
expect(client.set).toHaveBeenCalledWith({ key: 'key', value: '{"value":true}' });
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('redis-cache-client/E04 非法 TTL 在写入前被拒绝', async () => {
|
|
139
|
-
const client = createClient();
|
|
140
|
-
const provider = new RedisCacheProvider(client);
|
|
141
|
-
const invalidTtls = [-1, -0.5, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
|
142
|
-
|
|
143
|
-
for (const ttl of invalidTtls) {
|
|
144
|
-
await expect(provider.set('key', 'value', ttl)).rejects.toThrow(RangeError);
|
|
145
|
-
}
|
|
146
|
-
expect(client.set).not.toHaveBeenCalled();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('redis-cache-client/R01 重复删除保持幂等', async () => {
|
|
150
|
-
const client = createClient();
|
|
151
|
-
const provider = new RedisCacheProvider(client);
|
|
152
|
-
|
|
153
|
-
await expect(provider.delete('key')).resolves.toBeUndefined();
|
|
154
|
-
await expect(provider.delete('key')).resolves.toBeUndefined();
|
|
155
|
-
|
|
156
|
-
expect(client.deleteMany).toHaveBeenCalledTimes(2);
|
|
157
|
-
expect(client.deleteMany).toHaveBeenNthCalledWith(1, ['key']);
|
|
158
|
-
expect(client.deleteMany).toHaveBeenNthCalledWith(2, ['key']);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('redis-cache-client/R02 重复写入由后完成的命令覆盖', async () => {
|
|
162
|
-
const client = createClient();
|
|
163
|
-
useStringStorage(client);
|
|
164
|
-
const provider = new RedisCacheProvider(client);
|
|
165
|
-
|
|
166
|
-
await provider.set('key', { version: 1 }, 60);
|
|
167
|
-
await provider.set('key', { version: 2 }, 120);
|
|
168
|
-
|
|
169
|
-
await expect(provider.get<{ version: number }>('key')).resolves.toEqual({ version: 2 });
|
|
170
|
-
expect(client.set).toHaveBeenNthCalledWith(1, {
|
|
171
|
-
key: 'key',
|
|
172
|
-
value: '{"version":1}',
|
|
173
|
-
ttlSeconds: 60,
|
|
174
|
-
});
|
|
175
|
-
expect(client.set).toHaveBeenNthCalledWith(2, {
|
|
176
|
-
key: 'key',
|
|
177
|
-
value: '{"version":2}',
|
|
178
|
-
ttlSeconds: 120,
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('redis-cache-client/E03 删除或清库异常传播', async () => {
|
|
183
|
-
const deleteClient = createClient();
|
|
184
|
-
const deleteProvider = new RedisCacheProvider(deleteClient);
|
|
185
|
-
const deleteError = new Error('DEL unavailable');
|
|
186
|
-
deleteClient.deleteMany.mockRejectedValue(deleteError);
|
|
187
|
-
const clearClient = createClient();
|
|
188
|
-
const clearProvider = new RedisCacheProvider(clearClient);
|
|
189
|
-
const clearError = new Error('FLUSHDB unavailable');
|
|
190
|
-
clearClient.flushDatabase.mockRejectedValue(clearError);
|
|
191
|
-
|
|
192
|
-
await expect(deleteProvider.delete('key')).rejects.toBe(deleteError);
|
|
193
|
-
await expect(clearProvider.clear()).rejects.toBe(clearError);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('cache-evict-allentries-prefix/pattern 末尾 * 作为前缀匹配', async () => {
|
|
197
|
-
const client = createClient();
|
|
198
|
-
const provider = new RedisCacheProvider(client);
|
|
199
|
-
const existingKeys = new Set(['name:1', 'name:2', 'order:1']);
|
|
200
|
-
client.scan.mockResolvedValue({ cursor: '0', keys: ['name:1', 'name:2'] });
|
|
201
|
-
client.deleteMany.mockImplementation((keys): Promise<void> => {
|
|
202
|
-
for (const key of keys) {
|
|
203
|
-
existingKeys.delete(key);
|
|
204
|
-
}
|
|
205
|
-
return Promise.resolve();
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
await provider.deleteByPattern('name*');
|
|
209
|
-
|
|
210
|
-
expect(client.scan).toHaveBeenCalledWith({ cursor: '0', pattern: 'name*', count: 100 });
|
|
211
|
-
expect(client.deleteMany).toHaveBeenCalledWith(['name:1', 'name:2']);
|
|
212
|
-
expect(existingKeys).toEqual(new Set(['order:1']));
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('cache-evict-allentries-prefix/RedisCacheProvider 使用 SCAN 迭代删除', async () => {
|
|
216
|
-
const client = createClient();
|
|
217
|
-
const provider = new RedisCacheProvider(client);
|
|
218
|
-
client.scan
|
|
219
|
-
.mockResolvedValueOnce({ cursor: '7', keys: ['name:1'] })
|
|
220
|
-
.mockResolvedValueOnce({ cursor: '0', keys: ['name:2'] });
|
|
221
|
-
|
|
222
|
-
await provider.deleteByPattern('name*');
|
|
223
|
-
|
|
224
|
-
expect(client.scan.mock.calls).toEqual([
|
|
225
|
-
[{ cursor: '0', pattern: 'name*', count: 100 }],
|
|
226
|
-
[{ cursor: '7', pattern: 'name*', count: 100 }],
|
|
227
|
-
]);
|
|
228
|
-
expect(client.deleteMany.mock.calls).toEqual([[['name:1']], [['name:2']]]);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('cache-evict-allentries-prefix/F04 多页、空页与重复 key 均能完成扫描', async () => {
|
|
232
|
-
const client = createClient();
|
|
233
|
-
const provider = new RedisCacheProvider(client);
|
|
234
|
-
client.scan
|
|
235
|
-
.mockResolvedValueOnce({ cursor: '4', keys: ['name:1'] })
|
|
236
|
-
.mockResolvedValueOnce({ cursor: '8', keys: [] })
|
|
237
|
-
.mockResolvedValueOnce({ cursor: '0', keys: ['name:1', 'name:2'] });
|
|
238
|
-
|
|
239
|
-
await provider.deleteByPattern('name*');
|
|
240
|
-
|
|
241
|
-
expect(client.scan.mock.calls).toEqual([
|
|
242
|
-
[{ cursor: '0', pattern: 'name*', count: 100 }],
|
|
243
|
-
[{ cursor: '4', pattern: 'name*', count: 100 }],
|
|
244
|
-
[{ cursor: '8', pattern: 'name*', count: 100 }],
|
|
245
|
-
]);
|
|
246
|
-
expect(client.deleteMany.mock.calls).toEqual([[['name:1']], [['name:1', 'name:2']]]);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('cache-evict-allentries-prefix/F09 扫描或批量删除失败时停止并传播', async () => {
|
|
250
|
-
const scanClient = createClient();
|
|
251
|
-
const scanProvider = new RedisCacheProvider(scanClient);
|
|
252
|
-
const scanError = new Error('SCAN unavailable');
|
|
253
|
-
scanClient.scan.mockRejectedValue(scanError);
|
|
254
|
-
|
|
255
|
-
await expect(scanProvider.deleteByPattern('name*')).rejects.toBe(scanError);
|
|
256
|
-
expect(scanClient.deleteMany).not.toHaveBeenCalled();
|
|
257
|
-
|
|
258
|
-
const deleteClient = createClient();
|
|
259
|
-
const deleteProvider = new RedisCacheProvider(deleteClient);
|
|
260
|
-
const deleteError = new Error('second page DEL unavailable');
|
|
261
|
-
deleteClient.scan
|
|
262
|
-
.mockResolvedValueOnce({ cursor: '5', keys: ['name:1'] })
|
|
263
|
-
.mockResolvedValueOnce({ cursor: '9', keys: ['name:2'] });
|
|
264
|
-
deleteClient.deleteMany.mockResolvedValueOnce(undefined).mockRejectedValueOnce(deleteError);
|
|
265
|
-
|
|
266
|
-
await expect(deleteProvider.deleteByPattern('name*')).rejects.toBe(deleteError);
|
|
267
|
-
expect(deleteClient.scan).toHaveBeenCalledTimes(2);
|
|
268
|
-
expect(deleteClient.deleteMany.mock.calls).toEqual([[['name:1']], [['name:2']]]);
|
|
269
|
-
});
|
|
1
|
+
import { RedisCacheClient } from '../src/core/redis-cache-client';
|
|
2
|
+
import { RedisCacheProvider } from '../src/core/redis-cache';
|
|
3
|
+
|
|
4
|
+
function createClient(): jest.Mocked<RedisCacheClient> {
|
|
5
|
+
const client = {
|
|
6
|
+
get: jest.fn<Promise<string | null>, Parameters<RedisCacheClient['get']>>(),
|
|
7
|
+
set: jest.fn<Promise<void>, Parameters<RedisCacheClient['set']>>(),
|
|
8
|
+
deleteMany: jest.fn<Promise<void>, Parameters<RedisCacheClient['deleteMany']>>(),
|
|
9
|
+
scan: jest.fn<ReturnType<RedisCacheClient['scan']>, Parameters<RedisCacheClient['scan']>>(),
|
|
10
|
+
flushDatabase: jest.fn<Promise<void>, Parameters<RedisCacheClient['flushDatabase']>>(),
|
|
11
|
+
} satisfies jest.Mocked<RedisCacheClient>;
|
|
12
|
+
client.get.mockResolvedValue(null);
|
|
13
|
+
client.set.mockResolvedValue(undefined);
|
|
14
|
+
client.deleteMany.mockResolvedValue(undefined);
|
|
15
|
+
client.scan.mockResolvedValue({ cursor: '0', keys: [] });
|
|
16
|
+
client.flushDatabase.mockResolvedValue(undefined);
|
|
17
|
+
return client;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function useStringStorage(client: jest.Mocked<RedisCacheClient>): Map<string, string> {
|
|
21
|
+
const values = new Map<string, string>();
|
|
22
|
+
client.set.mockImplementation(({ key, value }): Promise<void> => {
|
|
23
|
+
values.set(key, value);
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
});
|
|
26
|
+
client.get.mockImplementation((key): Promise<string | null> => Promise.resolve(values.get(key) ?? null));
|
|
27
|
+
return values;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('redis-cache-client/V01 不存在的 key 返回 cache miss', async () => {
|
|
31
|
+
const client = createClient();
|
|
32
|
+
const provider = new RedisCacheProvider(client);
|
|
33
|
+
|
|
34
|
+
await expect(provider.get('missing')).resolves.toBeUndefined();
|
|
35
|
+
|
|
36
|
+
expect(client.get).toHaveBeenCalledWith('missing');
|
|
37
|
+
expect(client.set).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('redis-cache-client/V03 非 JSON 字符串和空字符串保持原值', async () => {
|
|
41
|
+
const client = createClient();
|
|
42
|
+
const values = useStringStorage(client);
|
|
43
|
+
const provider = new RedisCacheProvider(client);
|
|
44
|
+
|
|
45
|
+
await provider.set('plain', 'not-json');
|
|
46
|
+
await provider.set('empty', '');
|
|
47
|
+
|
|
48
|
+
expect(values).toEqual(
|
|
49
|
+
new Map([
|
|
50
|
+
['plain', 'not-json'],
|
|
51
|
+
['empty', ''],
|
|
52
|
+
])
|
|
53
|
+
);
|
|
54
|
+
await expect(provider.get('plain')).resolves.toBe('not-json');
|
|
55
|
+
await expect(provider.get('empty')).resolves.toBe('');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('redis-cache-client/V04 JSON 文本字符串沿用解析行为', async () => {
|
|
59
|
+
const client = createClient();
|
|
60
|
+
const values = useStringStorage(client);
|
|
61
|
+
const provider = new RedisCacheProvider(client);
|
|
62
|
+
const cases: ReadonlyArray<readonly [string, unknown]> = [
|
|
63
|
+
['123', 123],
|
|
64
|
+
['true', true],
|
|
65
|
+
['null', null],
|
|
66
|
+
['[1,2]', [1, 2]],
|
|
67
|
+
['{"status":"open"}', { status: 'open' }],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const [source, expected] of cases) {
|
|
71
|
+
await provider.set(source, source);
|
|
72
|
+
expect(values.get(source)).toBe(source);
|
|
73
|
+
await expect(provider.get(source)).resolves.toEqual(expected);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('redis-cache-client/V06 缺省 TTL 与零 TTL 永不过期', async () => {
|
|
78
|
+
const client = createClient();
|
|
79
|
+
const provider = new RedisCacheProvider(client);
|
|
80
|
+
|
|
81
|
+
await provider.set('default-ttl', 'value');
|
|
82
|
+
await provider.set('zero-ttl', 'value', 0);
|
|
83
|
+
|
|
84
|
+
expect(client.set).toHaveBeenNthCalledWith(1, { key: 'default-ttl', value: 'value' });
|
|
85
|
+
expect(client.set).toHaveBeenNthCalledWith(2, { key: 'zero-ttl', value: 'value' });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('redis-cache-client/V07 序列化失败不写入', async () => {
|
|
89
|
+
const client = createClient();
|
|
90
|
+
const provider = new RedisCacheProvider(client);
|
|
91
|
+
const cyclic: { self?: unknown } = {};
|
|
92
|
+
cyclic.self = cyclic;
|
|
93
|
+
const values: readonly unknown[] = [cyclic, undefined, (): void => undefined, Symbol('cache')];
|
|
94
|
+
|
|
95
|
+
for (const value of values) {
|
|
96
|
+
await expect(provider.set('key', value)).rejects.toThrow(TypeError);
|
|
97
|
+
}
|
|
98
|
+
expect(client.set).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('redis-cache-client/V08 key 边界不被 Provider 改写', async () => {
|
|
102
|
+
const client = createClient();
|
|
103
|
+
const provider = new RedisCacheProvider(client);
|
|
104
|
+
const keys = ['', '订单:缓存[*]?'];
|
|
105
|
+
client.get.mockResolvedValue('value');
|
|
106
|
+
|
|
107
|
+
for (const key of keys) {
|
|
108
|
+
await provider.set(key, 'value');
|
|
109
|
+
await provider.get(key);
|
|
110
|
+
await provider.delete(key);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
expect(client.set.mock.calls.map(([request]) => request.key)).toEqual(keys);
|
|
114
|
+
expect(client.get.mock.calls.map(([key]) => key)).toEqual(keys);
|
|
115
|
+
expect(client.deleteMany.mock.calls.map(([deletedKeys]) => deletedKeys)).toEqual(keys.map((key) => [key]));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('redis-cache-client/E01 读取异常不是 cache miss', async () => {
|
|
119
|
+
const client = createClient();
|
|
120
|
+
const provider = new RedisCacheProvider(client);
|
|
121
|
+
const error = new Error('GET unavailable');
|
|
122
|
+
client.get.mockRejectedValue(error);
|
|
123
|
+
|
|
124
|
+
await expect(provider.get('key')).rejects.toBe(error);
|
|
125
|
+
expect(client.set).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('redis-cache-client/E02 写入异常传播', async () => {
|
|
129
|
+
const client = createClient();
|
|
130
|
+
const provider = new RedisCacheProvider(client);
|
|
131
|
+
const error = new Error('SET unavailable');
|
|
132
|
+
client.set.mockRejectedValue(error);
|
|
133
|
+
|
|
134
|
+
await expect(provider.set('key', { value: true })).rejects.toBe(error);
|
|
135
|
+
expect(client.set).toHaveBeenCalledWith({ key: 'key', value: '{"value":true}' });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('redis-cache-client/E04 非法 TTL 在写入前被拒绝', async () => {
|
|
139
|
+
const client = createClient();
|
|
140
|
+
const provider = new RedisCacheProvider(client);
|
|
141
|
+
const invalidTtls = [-1, -0.5, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY];
|
|
142
|
+
|
|
143
|
+
for (const ttl of invalidTtls) {
|
|
144
|
+
await expect(provider.set('key', 'value', ttl)).rejects.toThrow(RangeError);
|
|
145
|
+
}
|
|
146
|
+
expect(client.set).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('redis-cache-client/R01 重复删除保持幂等', async () => {
|
|
150
|
+
const client = createClient();
|
|
151
|
+
const provider = new RedisCacheProvider(client);
|
|
152
|
+
|
|
153
|
+
await expect(provider.delete('key')).resolves.toBeUndefined();
|
|
154
|
+
await expect(provider.delete('key')).resolves.toBeUndefined();
|
|
155
|
+
|
|
156
|
+
expect(client.deleteMany).toHaveBeenCalledTimes(2);
|
|
157
|
+
expect(client.deleteMany).toHaveBeenNthCalledWith(1, ['key']);
|
|
158
|
+
expect(client.deleteMany).toHaveBeenNthCalledWith(2, ['key']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('redis-cache-client/R02 重复写入由后完成的命令覆盖', async () => {
|
|
162
|
+
const client = createClient();
|
|
163
|
+
useStringStorage(client);
|
|
164
|
+
const provider = new RedisCacheProvider(client);
|
|
165
|
+
|
|
166
|
+
await provider.set('key', { version: 1 }, 60);
|
|
167
|
+
await provider.set('key', { version: 2 }, 120);
|
|
168
|
+
|
|
169
|
+
await expect(provider.get<{ version: number }>('key')).resolves.toEqual({ version: 2 });
|
|
170
|
+
expect(client.set).toHaveBeenNthCalledWith(1, {
|
|
171
|
+
key: 'key',
|
|
172
|
+
value: '{"version":1}',
|
|
173
|
+
ttlSeconds: 60,
|
|
174
|
+
});
|
|
175
|
+
expect(client.set).toHaveBeenNthCalledWith(2, {
|
|
176
|
+
key: 'key',
|
|
177
|
+
value: '{"version":2}',
|
|
178
|
+
ttlSeconds: 120,
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('redis-cache-client/E03 删除或清库异常传播', async () => {
|
|
183
|
+
const deleteClient = createClient();
|
|
184
|
+
const deleteProvider = new RedisCacheProvider(deleteClient);
|
|
185
|
+
const deleteError = new Error('DEL unavailable');
|
|
186
|
+
deleteClient.deleteMany.mockRejectedValue(deleteError);
|
|
187
|
+
const clearClient = createClient();
|
|
188
|
+
const clearProvider = new RedisCacheProvider(clearClient);
|
|
189
|
+
const clearError = new Error('FLUSHDB unavailable');
|
|
190
|
+
clearClient.flushDatabase.mockRejectedValue(clearError);
|
|
191
|
+
|
|
192
|
+
await expect(deleteProvider.delete('key')).rejects.toBe(deleteError);
|
|
193
|
+
await expect(clearProvider.clear()).rejects.toBe(clearError);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('cache-evict-allentries-prefix/pattern 末尾 * 作为前缀匹配', async () => {
|
|
197
|
+
const client = createClient();
|
|
198
|
+
const provider = new RedisCacheProvider(client);
|
|
199
|
+
const existingKeys = new Set(['name:1', 'name:2', 'order:1']);
|
|
200
|
+
client.scan.mockResolvedValue({ cursor: '0', keys: ['name:1', 'name:2'] });
|
|
201
|
+
client.deleteMany.mockImplementation((keys): Promise<void> => {
|
|
202
|
+
for (const key of keys) {
|
|
203
|
+
existingKeys.delete(key);
|
|
204
|
+
}
|
|
205
|
+
return Promise.resolve();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await provider.deleteByPattern('name*');
|
|
209
|
+
|
|
210
|
+
expect(client.scan).toHaveBeenCalledWith({ cursor: '0', pattern: 'name*', count: 100 });
|
|
211
|
+
expect(client.deleteMany).toHaveBeenCalledWith(['name:1', 'name:2']);
|
|
212
|
+
expect(existingKeys).toEqual(new Set(['order:1']));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('cache-evict-allentries-prefix/RedisCacheProvider 使用 SCAN 迭代删除', async () => {
|
|
216
|
+
const client = createClient();
|
|
217
|
+
const provider = new RedisCacheProvider(client);
|
|
218
|
+
client.scan
|
|
219
|
+
.mockResolvedValueOnce({ cursor: '7', keys: ['name:1'] })
|
|
220
|
+
.mockResolvedValueOnce({ cursor: '0', keys: ['name:2'] });
|
|
221
|
+
|
|
222
|
+
await provider.deleteByPattern('name*');
|
|
223
|
+
|
|
224
|
+
expect(client.scan.mock.calls).toEqual([
|
|
225
|
+
[{ cursor: '0', pattern: 'name*', count: 100 }],
|
|
226
|
+
[{ cursor: '7', pattern: 'name*', count: 100 }],
|
|
227
|
+
]);
|
|
228
|
+
expect(client.deleteMany.mock.calls).toEqual([[['name:1']], [['name:2']]]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('cache-evict-allentries-prefix/F04 多页、空页与重复 key 均能完成扫描', async () => {
|
|
232
|
+
const client = createClient();
|
|
233
|
+
const provider = new RedisCacheProvider(client);
|
|
234
|
+
client.scan
|
|
235
|
+
.mockResolvedValueOnce({ cursor: '4', keys: ['name:1'] })
|
|
236
|
+
.mockResolvedValueOnce({ cursor: '8', keys: [] })
|
|
237
|
+
.mockResolvedValueOnce({ cursor: '0', keys: ['name:1', 'name:2'] });
|
|
238
|
+
|
|
239
|
+
await provider.deleteByPattern('name*');
|
|
240
|
+
|
|
241
|
+
expect(client.scan.mock.calls).toEqual([
|
|
242
|
+
[{ cursor: '0', pattern: 'name*', count: 100 }],
|
|
243
|
+
[{ cursor: '4', pattern: 'name*', count: 100 }],
|
|
244
|
+
[{ cursor: '8', pattern: 'name*', count: 100 }],
|
|
245
|
+
]);
|
|
246
|
+
expect(client.deleteMany.mock.calls).toEqual([[['name:1']], [['name:1', 'name:2']]]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('cache-evict-allentries-prefix/F09 扫描或批量删除失败时停止并传播', async () => {
|
|
250
|
+
const scanClient = createClient();
|
|
251
|
+
const scanProvider = new RedisCacheProvider(scanClient);
|
|
252
|
+
const scanError = new Error('SCAN unavailable');
|
|
253
|
+
scanClient.scan.mockRejectedValue(scanError);
|
|
254
|
+
|
|
255
|
+
await expect(scanProvider.deleteByPattern('name*')).rejects.toBe(scanError);
|
|
256
|
+
expect(scanClient.deleteMany).not.toHaveBeenCalled();
|
|
257
|
+
|
|
258
|
+
const deleteClient = createClient();
|
|
259
|
+
const deleteProvider = new RedisCacheProvider(deleteClient);
|
|
260
|
+
const deleteError = new Error('second page DEL unavailable');
|
|
261
|
+
deleteClient.scan
|
|
262
|
+
.mockResolvedValueOnce({ cursor: '5', keys: ['name:1'] })
|
|
263
|
+
.mockResolvedValueOnce({ cursor: '9', keys: ['name:2'] });
|
|
264
|
+
deleteClient.deleteMany.mockResolvedValueOnce(undefined).mockRejectedValueOnce(deleteError);
|
|
265
|
+
|
|
266
|
+
await expect(deleteProvider.deleteByPattern('name*')).rejects.toBe(deleteError);
|
|
267
|
+
expect(deleteClient.scan).toHaveBeenCalledTimes(2);
|
|
268
|
+
expect(deleteClient.deleteMany.mock.calls).toEqual([[['name:1']], [['name:2']]]);
|
|
269
|
+
});
|