@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,142 +1,142 @@
1
- import { randomUUID } from 'node:crypto';
2
- import Redis from 'ioredis';
3
- import { createClient } from 'redis';
4
-
5
- /** 由测试持有的两种真实 Redis 连接及其精确清理范围。 */
6
- export interface RedisFixture {
7
- readonly io: Redis;
8
- readonly node: ReturnType<typeof createClient>;
9
- readonly database: number;
10
- readonly errors: Error[];
11
-
12
- /**
13
- * 分配只属于本夹具的唯一物理 key。
14
- * @returns 已登记且可由 close 精确清理的 key。
15
- */
16
- key(): string;
17
-
18
- /**
19
- * 登记由本夹具唯一 key 派生的物理 key,供 close 精确清理。
20
- * @param key 必须位于 cache-decorator-test 测试命名空间内的物理 key。
21
- * @returns 原 key,便于测试在构造数据时直接使用。
22
- * @throws key 不属于测试命名空间时拒绝,避免误删外部数据。
23
- */
24
- track(key: string): string;
25
-
26
- /**
27
- * 清理本夹具登记的 key,并关闭本夹具创建的两个连接。
28
- * @returns 清理与关闭完成后结束;重复调用保持幂等。
29
- * @throws Redis 清理命令失败时传播原始错误,但仍回收连接。
30
- */
31
- close(): Promise<void>;
32
- }
33
-
34
- function selectedDatabase(url: string): number {
35
- const pathname = new URL(url).pathname;
36
- if (pathname === '' || pathname === '/') {
37
- return 0;
38
- }
39
- const databaseText = pathname.slice(1);
40
- const database = Number(databaseText);
41
- if (!Number.isSafeInteger(database) || database < 0 || String(database) !== databaseText) {
42
- throw new Error('CACHE_DECORATOR_TEST_REDIS_URL must select a valid Redis database number');
43
- }
44
- return database;
45
- }
46
-
47
- async function createFixture(url: string, database: number): Promise<RedisFixture> {
48
- const errors: Error[] = [];
49
- const keys = new Set<string>();
50
- let closed = false;
51
- const io = new Redis(url, {
52
- lazyConnect: true,
53
- enableOfflineQueue: false,
54
- connectTimeout: 1000,
55
- commandTimeout: 1000,
56
- maxRetriesPerRequest: 0,
57
- retryStrategy: (): null => null,
58
- });
59
- const node = createClient({
60
- url,
61
- disableOfflineQueue: true,
62
- socket: { connectTimeout: 1000, reconnectStrategy: false },
63
- });
64
- io.on('error', (error: Error) => errors.push(error));
65
- node.on('error', (error: Error) => errors.push(error));
66
- try {
67
- await Promise.all([io.connect(), node.connect()]);
68
- } catch (error) {
69
- io.disconnect();
70
- if (node.isOpen) {
71
- node.destroy();
72
- }
73
- throw error;
74
- }
75
- return {
76
- io,
77
- node,
78
- database,
79
- errors,
80
- /** @inheritdoc */
81
- key(): string {
82
- const key = `cache-decorator-test:${randomUUID()}`;
83
- keys.add(key);
84
- return key;
85
- },
86
- /** @inheritdoc */
87
- track(key: string): string {
88
- if (!key.startsWith('cache-decorator-test:')) {
89
- throw new Error('Redis fixture can only track cache-decorator-test keys');
90
- }
91
- keys.add(key);
92
- return key;
93
- },
94
- /** @inheritdoc */
95
- async close(): Promise<void> {
96
- if (closed) {
97
- return;
98
- }
99
- closed = true;
100
- try {
101
- if (keys.size > 0 && node.isReady) {
102
- await node.del([...keys]);
103
- } else if (keys.size > 0 && io.status === 'ready') {
104
- await io.del(...keys);
105
- }
106
- } finally {
107
- if (io.status !== 'end') {
108
- io.disconnect();
109
- }
110
- if (node.isOpen) {
111
- node.destroy();
112
- }
113
- }
114
- },
115
- };
116
- }
117
-
118
- /**
119
- * 从显式测试 URL 创建两个真实客户端,不回退到默认地址或业务连接。
120
- * @param url `CACHE_DECORATOR_TEST_REDIS_URL` 提供的专用 Redis 地址。
121
- * @returns 已完成连接且由测试负责 close 的双客户端夹具。
122
- * @throws URL、连接或后续清理失败时传播错误。
123
- */
124
- export async function createRedisFixture(url: string): Promise<RedisFixture> {
125
- return createFixture(url, selectedDatabase(url));
126
- }
127
-
128
- /**
129
- * 为会执行 FLUSHDB 的场景创建显式非零数据库夹具。
130
- * @param url `CACHE_DECORATOR_TEST_REDIS_URL` 提供且路径包含非零数据库号的地址。
131
- * @returns 已连接到明确隔离数据库的双客户端夹具。
132
- * @throws URL 未显式选择非零数据库时拒绝,避免清理共享或默认数据库。
133
- */
134
- export async function createIsolatedDatabaseRedisFixture(url: string): Promise<RedisFixture> {
135
- const database = selectedDatabase(url);
136
- if (database === 0) {
137
- throw new Error(
138
- 'Database-level cache tests require CACHE_DECORATOR_TEST_REDIS_URL to select a non-zero database'
139
- );
140
- }
141
- return createFixture(url, database);
142
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import Redis from 'ioredis';
3
+ import { createClient } from 'redis';
4
+
5
+ /** 由测试持有的两种真实 Redis 连接及其精确清理范围。 */
6
+ export interface RedisFixture {
7
+ readonly io: Redis;
8
+ readonly node: ReturnType<typeof createClient>;
9
+ readonly database: number;
10
+ readonly errors: Error[];
11
+
12
+ /**
13
+ * 分配只属于本夹具的唯一物理 key。
14
+ * @returns 已登记且可由 close 精确清理的 key。
15
+ */
16
+ key(): string;
17
+
18
+ /**
19
+ * 登记由本夹具唯一 key 派生的物理 key,供 close 精确清理。
20
+ * @param key 必须位于 cache-decorator-test 测试命名空间内的物理 key。
21
+ * @returns 原 key,便于测试在构造数据时直接使用。
22
+ * @throws key 不属于测试命名空间时拒绝,避免误删外部数据。
23
+ */
24
+ track(key: string): string;
25
+
26
+ /**
27
+ * 清理本夹具登记的 key,并关闭本夹具创建的两个连接。
28
+ * @returns 清理与关闭完成后结束;重复调用保持幂等。
29
+ * @throws Redis 清理命令失败时传播原始错误,但仍回收连接。
30
+ */
31
+ close(): Promise<void>;
32
+ }
33
+
34
+ function selectedDatabase(url: string): number {
35
+ const pathname = new URL(url).pathname;
36
+ if (pathname === '' || pathname === '/') {
37
+ return 0;
38
+ }
39
+ const databaseText = pathname.slice(1);
40
+ const database = Number(databaseText);
41
+ if (!Number.isSafeInteger(database) || database < 0 || String(database) !== databaseText) {
42
+ throw new Error('CACHE_DECORATOR_TEST_REDIS_URL must select a valid Redis database number');
43
+ }
44
+ return database;
45
+ }
46
+
47
+ async function createFixture(url: string, database: number): Promise<RedisFixture> {
48
+ const errors: Error[] = [];
49
+ const keys = new Set<string>();
50
+ let closed = false;
51
+ const io = new Redis(url, {
52
+ lazyConnect: true,
53
+ enableOfflineQueue: false,
54
+ connectTimeout: 1000,
55
+ commandTimeout: 1000,
56
+ maxRetriesPerRequest: 0,
57
+ retryStrategy: (): null => null,
58
+ });
59
+ const node = createClient({
60
+ url,
61
+ disableOfflineQueue: true,
62
+ socket: { connectTimeout: 1000, reconnectStrategy: false },
63
+ });
64
+ io.on('error', (error: Error) => errors.push(error));
65
+ node.on('error', (error: Error) => errors.push(error));
66
+ try {
67
+ await Promise.all([io.connect(), node.connect()]);
68
+ } catch (error) {
69
+ io.disconnect();
70
+ if (node.isOpen) {
71
+ node.destroy();
72
+ }
73
+ throw error;
74
+ }
75
+ return {
76
+ io,
77
+ node,
78
+ database,
79
+ errors,
80
+ /** @inheritdoc */
81
+ key(): string {
82
+ const key = `cache-decorator-test:${randomUUID()}`;
83
+ keys.add(key);
84
+ return key;
85
+ },
86
+ /** @inheritdoc */
87
+ track(key: string): string {
88
+ if (!key.startsWith('cache-decorator-test:')) {
89
+ throw new Error('Redis fixture can only track cache-decorator-test keys');
90
+ }
91
+ keys.add(key);
92
+ return key;
93
+ },
94
+ /** @inheritdoc */
95
+ async close(): Promise<void> {
96
+ if (closed) {
97
+ return;
98
+ }
99
+ closed = true;
100
+ try {
101
+ if (keys.size > 0 && node.isReady) {
102
+ await node.del([...keys]);
103
+ } else if (keys.size > 0 && io.status === 'ready') {
104
+ await io.del(...keys);
105
+ }
106
+ } finally {
107
+ if (io.status !== 'end') {
108
+ io.disconnect();
109
+ }
110
+ if (node.isOpen) {
111
+ node.destroy();
112
+ }
113
+ }
114
+ },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * 从显式测试 URL 创建两个真实客户端,不回退到默认地址或业务连接。
120
+ * @param url `CACHE_DECORATOR_TEST_REDIS_URL` 提供的专用 Redis 地址。
121
+ * @returns 已完成连接且由测试负责 close 的双客户端夹具。
122
+ * @throws URL、连接或后续清理失败时传播错误。
123
+ */
124
+ export async function createRedisFixture(url: string): Promise<RedisFixture> {
125
+ return createFixture(url, selectedDatabase(url));
126
+ }
127
+
128
+ /**
129
+ * 为会执行 FLUSHDB 的场景创建显式非零数据库夹具。
130
+ * @param url `CACHE_DECORATOR_TEST_REDIS_URL` 提供且路径包含非零数据库号的地址。
131
+ * @returns 已连接到明确隔离数据库的双客户端夹具。
132
+ * @throws URL 未显式选择非零数据库时拒绝,避免清理共享或默认数据库。
133
+ */
134
+ export async function createIsolatedDatabaseRedisFixture(url: string): Promise<RedisFixture> {
135
+ const database = selectedDatabase(url);
136
+ if (database === 0) {
137
+ throw new Error(
138
+ 'Database-level cache tests require CACHE_DECORATOR_TEST_REDIS_URL to select a non-zero database'
139
+ );
140
+ }
141
+ return createFixture(url, database);
142
+ }
@@ -1,143 +1,143 @@
1
- import { createIoredisCacheClient } from '../src/adapters/ioredis-cache-client';
2
- import { IoredisCacheClientSource } from '../src/core/redis-cache-client';
3
-
4
- function createSource(keyPrefix?: string): jest.Mocked<IoredisCacheClientSource> {
5
- const source = {
6
- options: Object.freeze({ keyPrefix }),
7
- get: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['get']>>(),
8
- set: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['set']>>(),
9
- setex: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['setex']>>(),
10
- del: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['del']>>(),
11
- scan: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['scan']>>(),
12
- flushdb: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['flushdb']>>(),
13
- } satisfies jest.Mocked<IoredisCacheClientSource>;
14
- return source;
15
- }
16
-
17
- it('redis-cache-client/A01 ioredis 无 TTL 写入成功', async () => {
18
- const source = createSource();
19
- const client = createIoredisCacheClient(source);
20
- const request = Object.freeze({ key: 'order:1', value: '{"status":"open"}' });
21
- source.set.mockResolvedValue('OK');
22
-
23
- await expect(client.set(request)).resolves.toBeUndefined();
24
-
25
- expect(source.set).toHaveBeenCalledWith(request.key, request.value);
26
- expect(source.setex).not.toHaveBeenCalled();
27
- });
28
-
29
- it('redis-cache-client/A02 ioredis 带 TTL 写入成功', async () => {
30
- const source = createSource();
31
- const client = createIoredisCacheClient(source);
32
- const request = Object.freeze({ key: 'order:2', value: 'paid', ttlSeconds: 60 });
33
- source.setex.mockResolvedValue('OK');
34
-
35
- await expect(client.set(request)).resolves.toBeUndefined();
36
-
37
- expect(source.setex).toHaveBeenCalledWith(request.key, request.ttlSeconds, request.value);
38
- expect(source.set).not.toHaveBeenCalled();
39
- });
40
-
41
- it('redis-cache-client/A05 GET 响应规范化', async () => {
42
- const source = createSource();
43
- const client = createIoredisCacheClient(source);
44
- source.get.mockResolvedValueOnce('cached').mockResolvedValueOnce(null);
45
-
46
- await expect(client.get('present')).resolves.toBe('cached');
47
- await expect(client.get('missing')).resolves.toBeNull();
48
-
49
- expect(source.get).toHaveBeenNthCalledWith(1, 'present');
50
- expect(source.get).toHaveBeenNthCalledWith(2, 'missing');
51
- });
52
-
53
- it('redis-cache-client/A06 删除和清库响应规范化', async () => {
54
- const source = createSource();
55
- const client = createIoredisCacheClient(source);
56
- source.del.mockResolvedValueOnce(2).mockResolvedValueOnce(0);
57
- source.flushdb.mockResolvedValue('OK');
58
-
59
- await expect(client.deleteMany(['first', 'second'])).resolves.toBeUndefined();
60
- await expect(client.deleteMany(['missing'])).resolves.toBeUndefined();
61
- await expect(client.deleteMany([])).resolves.toBeUndefined();
62
- await expect(client.flushDatabase()).resolves.toBeUndefined();
63
-
64
- expect(source.del).toHaveBeenCalledTimes(2);
65
- expect(source.del).toHaveBeenNthCalledWith(1, 'first', 'second');
66
- expect(source.del).toHaveBeenNthCalledWith(2, 'missing');
67
- expect(source.flushdb).toHaveBeenCalledTimes(1);
68
- });
69
-
70
- it('redis-cache-client/A07 调用上下文与请求保持不变', async () => {
71
- const source = createSource('scope:');
72
- const client = createIoredisCacheClient(source);
73
- source.set.mockImplementation(function (this: IoredisCacheClientSource): Promise<unknown> {
74
- expect(this).toBe(source);
75
- return Promise.resolve('OK');
76
- });
77
- source.del.mockImplementation(function (this: IoredisCacheClientSource): Promise<unknown> {
78
- expect(this).toBe(source);
79
- return Promise.resolve(2);
80
- });
81
- source.scan.mockImplementation(function (this: IoredisCacheClientSource): Promise<unknown> {
82
- expect(this).toBe(source);
83
- return Promise.resolve(['0', ['scope:first', 'scope:second']]);
84
- });
85
- const writeRequest = Object.freeze({ key: 'first', value: 'value' });
86
- const keys = Object.freeze(['first', 'second']);
87
- const scanRequest = Object.freeze({ cursor: '0', pattern: 'first*', count: 100 });
88
-
89
- await client.set(writeRequest);
90
- await client.deleteMany(keys);
91
- await client.scan(scanRequest);
92
-
93
- expect(writeRequest).toEqual({ key: 'first', value: 'value' });
94
- expect(keys).toEqual(['first', 'second']);
95
- expect(scanRequest).toEqual({ cursor: '0', pattern: 'first*', count: 100 });
96
- });
97
-
98
- it('redis-cache-client/A08 非标准响应明确失败', async () => {
99
- const source = createSource();
100
- const client = createIoredisCacheClient(source);
101
- source.get.mockResolvedValue(false);
102
- source.set.mockResolvedValue(null);
103
- source.setex.mockResolvedValue(Buffer.from('OK'));
104
- source.del.mockResolvedValue(-1);
105
- source.scan.mockResolvedValue({ cursor: '0', keys: [] });
106
- source.flushdb.mockResolvedValue('PONG');
107
-
108
- await expect(client.get('key')).rejects.toThrow(TypeError);
109
- await expect(client.set({ key: 'key', value: 'value' })).rejects.toThrow(TypeError);
110
- await expect(client.set({ key: 'key', value: 'value', ttlSeconds: 1 })).rejects.toThrow(TypeError);
111
- await expect(client.deleteMany(['key'])).rejects.toThrow(TypeError);
112
- await expect(client.scan({ cursor: '0', pattern: '*', count: 100 })).rejects.toThrow(TypeError);
113
- await expect(client.flushDatabase()).rejects.toThrow(TypeError);
114
- });
115
-
116
- it('cache-evict-allentries-prefix/F05 ioredis keyPrefix 下删除逻辑 pattern', async () => {
117
- const source = createSource('cache:');
118
- const client = createIoredisCacheClient(source);
119
- source.scan.mockResolvedValue(['0', ['cache:name:1', 'cache:name:2']]);
120
- source.del.mockResolvedValue(2);
121
-
122
- const page = await client.scan({ cursor: '0', pattern: 'name*', count: 100 });
123
- await client.deleteMany(page.keys);
124
-
125
- expect(source.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:name*', 'COUNT', 100);
126
- expect(page).toEqual({ cursor: '0', keys: ['name:1', 'name:2'] });
127
- expect(source.del).toHaveBeenCalledWith('name:1', 'name:2');
128
- });
129
-
130
- it('cache-evict-allentries-prefix/F06 ioredis 特殊字符 keyPrefix 被按字面量匹配', async () => {
131
- const keyPrefix = 'scope\\*?[]:';
132
- const source = createSource(keyPrefix);
133
- const client = createIoredisCacheClient(source);
134
- source.scan.mockResolvedValue(['0', [`${keyPrefix}name:1`]]);
135
- const expectedPattern = ['scope', '\\\\', '\\*', '\\?', '\\[', '\\]', ':name*'].join('');
136
-
137
- await expect(client.scan({ cursor: '0', pattern: 'name*', count: 100 })).resolves.toEqual({
138
- cursor: '0',
139
- keys: ['name:1'],
140
- });
141
-
142
- expect(source.scan).toHaveBeenCalledWith('0', 'MATCH', expectedPattern, 'COUNT', 100);
143
- });
1
+ import { createIoredisCacheClient } from '../src/adapters/ioredis-cache-client';
2
+ import { IoredisCacheClientSource } from '../src/core/redis-cache-client';
3
+
4
+ function createSource(keyPrefix?: string): jest.Mocked<IoredisCacheClientSource> {
5
+ const source = {
6
+ options: Object.freeze({ keyPrefix }),
7
+ get: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['get']>>(),
8
+ set: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['set']>>(),
9
+ setex: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['setex']>>(),
10
+ del: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['del']>>(),
11
+ scan: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['scan']>>(),
12
+ flushdb: jest.fn<Promise<unknown>, Parameters<IoredisCacheClientSource['flushdb']>>(),
13
+ } satisfies jest.Mocked<IoredisCacheClientSource>;
14
+ return source;
15
+ }
16
+
17
+ it('redis-cache-client/A01 ioredis 无 TTL 写入成功', async () => {
18
+ const source = createSource();
19
+ const client = createIoredisCacheClient(source);
20
+ const request = Object.freeze({ key: 'order:1', value: '{"status":"open"}' });
21
+ source.set.mockResolvedValue('OK');
22
+
23
+ await expect(client.set(request)).resolves.toBeUndefined();
24
+
25
+ expect(source.set).toHaveBeenCalledWith(request.key, request.value);
26
+ expect(source.setex).not.toHaveBeenCalled();
27
+ });
28
+
29
+ it('redis-cache-client/A02 ioredis 带 TTL 写入成功', async () => {
30
+ const source = createSource();
31
+ const client = createIoredisCacheClient(source);
32
+ const request = Object.freeze({ key: 'order:2', value: 'paid', ttlSeconds: 60 });
33
+ source.setex.mockResolvedValue('OK');
34
+
35
+ await expect(client.set(request)).resolves.toBeUndefined();
36
+
37
+ expect(source.setex).toHaveBeenCalledWith(request.key, request.ttlSeconds, request.value);
38
+ expect(source.set).not.toHaveBeenCalled();
39
+ });
40
+
41
+ it('redis-cache-client/A05 GET 响应规范化', async () => {
42
+ const source = createSource();
43
+ const client = createIoredisCacheClient(source);
44
+ source.get.mockResolvedValueOnce('cached').mockResolvedValueOnce(null);
45
+
46
+ await expect(client.get('present')).resolves.toBe('cached');
47
+ await expect(client.get('missing')).resolves.toBeNull();
48
+
49
+ expect(source.get).toHaveBeenNthCalledWith(1, 'present');
50
+ expect(source.get).toHaveBeenNthCalledWith(2, 'missing');
51
+ });
52
+
53
+ it('redis-cache-client/A06 删除和清库响应规范化', async () => {
54
+ const source = createSource();
55
+ const client = createIoredisCacheClient(source);
56
+ source.del.mockResolvedValueOnce(2).mockResolvedValueOnce(0);
57
+ source.flushdb.mockResolvedValue('OK');
58
+
59
+ await expect(client.deleteMany(['first', 'second'])).resolves.toBeUndefined();
60
+ await expect(client.deleteMany(['missing'])).resolves.toBeUndefined();
61
+ await expect(client.deleteMany([])).resolves.toBeUndefined();
62
+ await expect(client.flushDatabase()).resolves.toBeUndefined();
63
+
64
+ expect(source.del).toHaveBeenCalledTimes(2);
65
+ expect(source.del).toHaveBeenNthCalledWith(1, 'first', 'second');
66
+ expect(source.del).toHaveBeenNthCalledWith(2, 'missing');
67
+ expect(source.flushdb).toHaveBeenCalledTimes(1);
68
+ });
69
+
70
+ it('redis-cache-client/A07 调用上下文与请求保持不变', async () => {
71
+ const source = createSource('scope:');
72
+ const client = createIoredisCacheClient(source);
73
+ source.set.mockImplementation(function (this: IoredisCacheClientSource): Promise<unknown> {
74
+ expect(this).toBe(source);
75
+ return Promise.resolve('OK');
76
+ });
77
+ source.del.mockImplementation(function (this: IoredisCacheClientSource): Promise<unknown> {
78
+ expect(this).toBe(source);
79
+ return Promise.resolve(2);
80
+ });
81
+ source.scan.mockImplementation(function (this: IoredisCacheClientSource): Promise<unknown> {
82
+ expect(this).toBe(source);
83
+ return Promise.resolve(['0', ['scope:first', 'scope:second']]);
84
+ });
85
+ const writeRequest = Object.freeze({ key: 'first', value: 'value' });
86
+ const keys = Object.freeze(['first', 'second']);
87
+ const scanRequest = Object.freeze({ cursor: '0', pattern: 'first*', count: 100 });
88
+
89
+ await client.set(writeRequest);
90
+ await client.deleteMany(keys);
91
+ await client.scan(scanRequest);
92
+
93
+ expect(writeRequest).toEqual({ key: 'first', value: 'value' });
94
+ expect(keys).toEqual(['first', 'second']);
95
+ expect(scanRequest).toEqual({ cursor: '0', pattern: 'first*', count: 100 });
96
+ });
97
+
98
+ it('redis-cache-client/A08 非标准响应明确失败', async () => {
99
+ const source = createSource();
100
+ const client = createIoredisCacheClient(source);
101
+ source.get.mockResolvedValue(false);
102
+ source.set.mockResolvedValue(null);
103
+ source.setex.mockResolvedValue(Buffer.from('OK'));
104
+ source.del.mockResolvedValue(-1);
105
+ source.scan.mockResolvedValue({ cursor: '0', keys: [] });
106
+ source.flushdb.mockResolvedValue('PONG');
107
+
108
+ await expect(client.get('key')).rejects.toThrow(TypeError);
109
+ await expect(client.set({ key: 'key', value: 'value' })).rejects.toThrow(TypeError);
110
+ await expect(client.set({ key: 'key', value: 'value', ttlSeconds: 1 })).rejects.toThrow(TypeError);
111
+ await expect(client.deleteMany(['key'])).rejects.toThrow(TypeError);
112
+ await expect(client.scan({ cursor: '0', pattern: '*', count: 100 })).rejects.toThrow(TypeError);
113
+ await expect(client.flushDatabase()).rejects.toThrow(TypeError);
114
+ });
115
+
116
+ it('cache-evict-allentries-prefix/F05 ioredis keyPrefix 下删除逻辑 pattern', async () => {
117
+ const source = createSource('cache:');
118
+ const client = createIoredisCacheClient(source);
119
+ source.scan.mockResolvedValue(['0', ['cache:name:1', 'cache:name:2']]);
120
+ source.del.mockResolvedValue(2);
121
+
122
+ const page = await client.scan({ cursor: '0', pattern: 'name*', count: 100 });
123
+ await client.deleteMany(page.keys);
124
+
125
+ expect(source.scan).toHaveBeenCalledWith('0', 'MATCH', 'cache:name*', 'COUNT', 100);
126
+ expect(page).toEqual({ cursor: '0', keys: ['name:1', 'name:2'] });
127
+ expect(source.del).toHaveBeenCalledWith('name:1', 'name:2');
128
+ });
129
+
130
+ it('cache-evict-allentries-prefix/F06 ioredis 特殊字符 keyPrefix 被按字面量匹配', async () => {
131
+ const keyPrefix = 'scope\\*?[]:';
132
+ const source = createSource(keyPrefix);
133
+ const client = createIoredisCacheClient(source);
134
+ source.scan.mockResolvedValue(['0', [`${keyPrefix}name:1`]]);
135
+ const expectedPattern = ['scope', '\\\\', '\\*', '\\?', '\\[', '\\]', ':name*'].join('');
136
+
137
+ await expect(client.scan({ cursor: '0', pattern: 'name*', count: 100 })).resolves.toEqual({
138
+ cursor: '0',
139
+ keys: ['name:1'],
140
+ });
141
+
142
+ expect(source.scan).toHaveBeenCalledWith('0', 'MATCH', expectedPattern, 'COUNT', 100);
143
+ });
@@ -0,0 +1,51 @@
1
+ import type Redis from 'ioredis';
2
+ import { readLegacyRedisCache, writeLegacyRedisCache } from './helpers/legacy-redis-cache';
3
+
4
+ interface LegacyRedisHarness {
5
+ readonly client: Redis;
6
+ readonly get: jest.Mock<Promise<string | null>, [string]>;
7
+ readonly set: jest.Mock<Promise<unknown>, [string, string]>;
8
+ readonly setex: jest.Mock<Promise<unknown>, [string, number, string]>;
9
+ }
10
+
11
+ /** 创建只实现 legacy helper 所需命令的 Redis 测试替身。 */
12
+ function createLegacyRedisHarness(): LegacyRedisHarness {
13
+ const get = jest.fn<Promise<string | null>, [string]>();
14
+ const set = jest.fn<Promise<unknown>, [string, string]>().mockResolvedValue('OK');
15
+ const setex = jest.fn<Promise<unknown>, [string, number, string]>().mockResolvedValue('OK');
16
+ return { client: { get, set, setex } as unknown as Redis, get, set, setex };
17
+ }
18
+
19
+ it('旧读取方可 JSON 解析新 envelope,但按旧异常分支抛出未解码对象', async () => {
20
+ const harness = createLegacyRedisHarness();
21
+ const envelope = {
22
+ kind: '@jintianxiayu/cache-decorator/error',
23
+ version: 1,
24
+ payload: { type: 'error', name: 'Error', message: 'not found' },
25
+ };
26
+ harness.get.mockResolvedValue(JSON.stringify({ error: envelope }));
27
+
28
+ const legacyEntry = await readLegacyRedisCache(harness.client, 'users:1');
29
+ let legacyThrown: unknown;
30
+ try {
31
+ if (typeof legacyEntry === 'object' && legacyEntry !== null && 'error' in legacyEntry) {
32
+ throw legacyEntry.error;
33
+ }
34
+ } catch (error) {
35
+ legacyThrown = error;
36
+ }
37
+
38
+ expect(harness.get).toHaveBeenCalledWith('users:1');
39
+ expect(legacyThrown).toEqual(envelope);
40
+ expect(legacyThrown).not.toBeInstanceOf(Error);
41
+ });
42
+
43
+ it('旧写入 helper 保持原 key、JSON 内容与秒级 TTL 请求', async () => {
44
+ const harness = createLegacyRedisHarness();
45
+
46
+ await writeLegacyRedisCache(harness.client, 'users:1', { value: { id: 1 } }, 30);
47
+ await writeLegacyRedisCache(harness.client, 'users:2', { value: { id: 2 } });
48
+
49
+ expect(harness.setex).toHaveBeenCalledWith('users:1', 30, '{"value":{"id":1}}');
50
+ expect(harness.set).toHaveBeenCalledWith('users:2', '{"value":{"id":2}}');
51
+ });