@jintianxiayu/cache-decorator 0.1.3 → 1.0.1

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 (92) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +306 -79
  3. package/dist/adapters/ioredis-cache-client.d.ts +9 -0
  4. package/dist/adapters/ioredis-cache-client.d.ts.map +1 -0
  5. package/dist/adapters/ioredis-cache-client.js +73 -0
  6. package/dist/adapters/ioredis-cache-client.js.map +1 -0
  7. package/dist/adapters/node-redis-cache-client.d.ts +10 -0
  8. package/dist/adapters/node-redis-cache-client.d.ts.map +1 -0
  9. package/dist/adapters/node-redis-cache-client.js +81 -0
  10. package/dist/adapters/node-redis-cache-client.js.map +1 -0
  11. package/dist/adapters/redis-key-prefix.d.ts +22 -0
  12. package/dist/adapters/redis-key-prefix.d.ts.map +1 -0
  13. package/dist/adapters/redis-key-prefix.js +41 -0
  14. package/dist/adapters/redis-key-prefix.js.map +1 -0
  15. package/dist/core/cache-error.d.ts +75 -0
  16. package/dist/core/cache-error.d.ts.map +1 -0
  17. package/dist/core/cache-error.js +168 -0
  18. package/dist/core/cache-error.js.map +1 -0
  19. package/dist/core/cache-logger.d.ts +29 -0
  20. package/dist/core/cache-logger.d.ts.map +1 -0
  21. package/dist/core/cache-logger.js +72 -0
  22. package/dist/core/cache-logger.js.map +1 -0
  23. package/dist/core/cache-provider-registry.d.ts.map +1 -1
  24. package/dist/core/cache-provider-registry.js.map +1 -1
  25. package/dist/core/cache-provider.d.ts.map +1 -1
  26. package/dist/core/key-builder.d.ts.map +1 -1
  27. package/dist/core/key-builder.js +3 -2
  28. package/dist/core/key-builder.js.map +1 -1
  29. package/dist/core/native-cache.d.ts.map +1 -1
  30. package/dist/core/native-cache.js +2 -1
  31. package/dist/core/native-cache.js.map +1 -1
  32. package/dist/core/pending-cache.d.ts.map +1 -1
  33. package/dist/core/pending-cache.js +6 -3
  34. package/dist/core/pending-cache.js.map +1 -1
  35. package/dist/core/redis-cache-client.d.ts +156 -0
  36. package/dist/core/redis-cache-client.d.ts.map +1 -0
  37. package/dist/core/redis-cache-client.js +3 -0
  38. package/dist/core/redis-cache-client.js.map +1 -0
  39. package/dist/core/redis-cache.d.ts +39 -7
  40. package/dist/core/redis-cache.d.ts.map +1 -1
  41. package/dist/core/redis-cache.js +63 -22
  42. package/dist/core/redis-cache.js.map +1 -1
  43. package/dist/decorators/cache-evict.d.ts +1 -1
  44. package/dist/decorators/cache-evict.d.ts.map +1 -1
  45. package/dist/decorators/cache-evict.js +68 -14
  46. package/dist/decorators/cache-evict.js.map +1 -1
  47. package/dist/decorators/cache.d.ts +7 -1
  48. package/dist/decorators/cache.d.ts.map +1 -1
  49. package/dist/decorators/cache.js +169 -24
  50. package/dist/decorators/cache.js.map +1 -1
  51. package/dist/index.d.ts +3 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -0
  54. package/dist/index.js.map +1 -1
  55. package/jest.config.js +1 -1
  56. package/package.json +26 -29
  57. package/src/adapters/ioredis-cache-client.ts +89 -0
  58. package/src/adapters/node-redis-cache-client.ts +95 -0
  59. package/src/adapters/redis-key-prefix.ts +38 -0
  60. package/src/core/cache-error.ts +233 -0
  61. package/src/core/cache-logger.ts +116 -0
  62. package/src/core/key-builder.ts +3 -1
  63. package/src/core/native-cache.ts +3 -1
  64. package/src/core/pending-cache.ts +6 -3
  65. package/src/core/redis-cache-client.ts +160 -0
  66. package/src/core/redis-cache.ts +68 -20
  67. package/src/decorators/cache-evict.ts +76 -16
  68. package/src/decorators/cache.ts +220 -26
  69. package/src/index.ts +3 -0
  70. package/test/cache-evict-logging.test.ts +362 -0
  71. package/test/cache-logger.integration.test.ts +129 -0
  72. package/test/cache-logger.test.ts +156 -0
  73. package/test/cache-logging.test.ts +852 -0
  74. package/test/cache.test.ts +717 -34
  75. package/test/fixtures/cache-logger-child.mjs +108 -0
  76. package/test/helpers/legacy-redis-cache.ts +41 -0
  77. package/test/helpers/package-consumer.ts +231 -0
  78. package/test/helpers/redis-fixture.ts +142 -0
  79. package/test/ioredis-cache-client.test.ts +143 -0
  80. package/test/legacy-redis-cache.test.ts +51 -0
  81. package/test/native-cache.test.ts +9 -3
  82. package/test/node-redis-cache-client.test.ts +149 -0
  83. package/test/pending-cache.test.ts +11 -0
  84. package/test/redis-cache-client-lifecycle.test.ts +112 -0
  85. package/test/redis-cache-client-types.test.ts +184 -0
  86. package/test/redis-cache-client.integration.test.ts +355 -0
  87. package/test/redis-cache-decorator.test.ts +540 -0
  88. package/test/redis-cache-package.integration.test.ts +328 -0
  89. package/test/redis-cache-provider.test.ts +269 -0
  90. package/test/type-contract/contract.ts +119 -0
  91. package/test/type-contract/tsconfig.json +12 -0
  92. package/tsconfig.json +1 -1
@@ -0,0 +1,108 @@
1
+ import assert from 'node:assert/strict';
2
+ import { realpathSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import cachePackage from '../../dist/index.js';
7
+ import loggerPackage from '../../../logger/dist/index.js';
8
+
9
+ const { Cache, CacheProviderRegistry, MemoryCacheProvider } = cachePackage;
10
+ const { LoggerContext, LoggerFactory } = loggerPackage;
11
+ const CACHE_LOGGER_NAME = '@jintianxiayu/cache-decorator';
12
+ const profileLevel = process.argv[2];
13
+ const fixtureDirectory = dirname(fileURLToPath(import.meta.url));
14
+ const fixtureRequire = createRequire(import.meta.url);
15
+ if (profileLevel !== 'debug' && profileLevel !== 'error') {
16
+ throw new Error('Expected cache Logger profile level: debug or error');
17
+ }
18
+
19
+ /** 将 legacy method decorator 应用到 JavaScript fixture 的指定方法。 */
20
+ function decorateMethod(target, methodName, decorator) {
21
+ const descriptor = Object.getOwnPropertyDescriptor(target, methodName);
22
+ assert.ok(descriptor);
23
+ decorator(target, methodName, descriptor);
24
+ Object.defineProperty(target, methodName, descriptor);
25
+ }
26
+
27
+ /** 在应用初始化 Logger 后执行正常缓存和 Provider 失败调用。 */
28
+ async function main() {
29
+ LoggerFactory.init({
30
+ root: {
31
+ level: 'error',
32
+ console: { enabled: false, colors: false, format: 'json' },
33
+ file: { enabled: false },
34
+ },
35
+ loggers: {
36
+ [CACHE_LOGGER_NAME]: {
37
+ level: profileLevel,
38
+ console: { enabled: true, colors: false, format: 'json' },
39
+ file: { enabled: false },
40
+ },
41
+ },
42
+ masking: { enabled: true },
43
+ processErrors: {
44
+ uncaughtException: false,
45
+ unhandledRejection: false,
46
+ exitOnError: false,
47
+ },
48
+ });
49
+
50
+ const cachePackageRoot = resolve(fixtureDirectory, '../..');
51
+ const requireFromCache = createRequire(join(cachePackageRoot, 'package.json'));
52
+ const directLoggerEntry = fixtureRequire.resolve('@jintianxiayu/logger');
53
+ const cacheLoggerEntry = requireFromCache.resolve('@jintianxiayu/logger');
54
+ assert.equal(realpathSync(directLoggerEntry), realpathSync(cacheLoggerEntry));
55
+ const namedLogger = LoggerFactory.getLogger(CACHE_LOGGER_NAME);
56
+
57
+ const memoryProvider = new MemoryCacheProvider();
58
+ const providerError = Object.assign(new Error('redis unavailable'), {
59
+ password: 'provider-password-secret',
60
+ email: 'provider@example.com',
61
+ });
62
+ const failingProvider = {
63
+ get: async () => {
64
+ throw providerError;
65
+ },
66
+ set: () => undefined,
67
+ delete: () => undefined,
68
+ clear: () => undefined,
69
+ deleteByPattern: () => undefined,
70
+ };
71
+
72
+ class SuccessService {
73
+ getUser() {
74
+ return { id: 17, password: 'business-value-secret' };
75
+ }
76
+ }
77
+ decorateMethod(SuccessService.prototype, 'getUser', Cache('fixture-users', { providerName: 'memory' }));
78
+
79
+ class FailureService {
80
+ getUser() {
81
+ throw new Error('business method must not run');
82
+ }
83
+ }
84
+ decorateMethod(FailureService.prototype, 'getUser', Cache('fixture-failure-users', { providerName: 'failing' }));
85
+
86
+ CacheProviderRegistry.register('memory', memoryProvider);
87
+ CacheProviderRegistry.register('failing', failingProvider);
88
+ try {
89
+ const result = await LoggerContext.withContext({ traceId: 'cache-fixture-trace' }, () =>
90
+ new SuccessService().getUser()
91
+ );
92
+ assert.deepEqual(result, { id: 17, password: 'business-value-secret' });
93
+
94
+ const failure = LoggerContext.withContext({ traceId: 'cache-fixture-trace' }, () =>
95
+ new FailureService().getUser()
96
+ );
97
+ await assert.rejects(failure, (error) => error === providerError);
98
+ assert.strictEqual(LoggerFactory.getLogger(CACHE_LOGGER_NAME), namedLogger);
99
+ } finally {
100
+ CacheProviderRegistry.clear();
101
+ await LoggerFactory.shutdown({ timeout: 2_000 });
102
+ }
103
+ }
104
+
105
+ main().catch((error) => {
106
+ process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
107
+ process.exitCode = 1;
108
+ });
@@ -0,0 +1,41 @@
1
+ import Redis from 'ioredis';
2
+
3
+ /**
4
+ * 使用 0.1.3 的字符串/JSON 与秒级 TTL 协议写入兼容性数据。
5
+ * @param client 由测试创建并持有的 ioredis 连接。
6
+ * @param key 旧版本收到的原始缓存 key。
7
+ * @param value 旧版本支持的字符串或 JSON 可序列化值。
8
+ * @param ttl 可选的秒级 TTL;0 与 undefined 使用普通 SET。
9
+ * @returns 旧版写入命令成功后完成。
10
+ * @throws 无法序列化或 Redis 命令失败时传播错误。
11
+ */
12
+ export async function writeLegacyRedisCache(client: Redis, key: string, value: unknown, ttl?: number): Promise<void> {
13
+ const serialized = typeof value === 'string' ? value : JSON.stringify(value);
14
+ if (typeof serialized !== 'string') {
15
+ throw new TypeError('Legacy cache fixture value must serialize to a string');
16
+ }
17
+ if (ttl) {
18
+ await client.setex(key, ttl, serialized);
19
+ return;
20
+ }
21
+ await client.set(key, serialized);
22
+ }
23
+
24
+ /**
25
+ * 使用旧版普通 JSON 读取规则恢复缓存值,不理解当前版本的异常 envelope。
26
+ * @param client 由测试创建并持有的 ioredis 连接。
27
+ * @param key 旧版本收到的原始缓存 key。
28
+ * @returns miss 时返回 undefined;合法 JSON 返回解析值,其余字符串保持原值。
29
+ * @throws Redis GET 命令失败时传播原始错误。
30
+ */
31
+ export async function readLegacyRedisCache(client: Redis, key: string): Promise<unknown> {
32
+ const value = await client.get(key);
33
+ if (value === null) {
34
+ return undefined;
35
+ }
36
+ try {
37
+ return JSON.parse(value) as unknown;
38
+ } catch {
39
+ return value;
40
+ }
41
+ }
@@ -0,0 +1,231 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, dirname, join, resolve, sep } from 'node:path';
5
+
6
+ export const packageRoot = resolve(__dirname, '../..');
7
+
8
+ /**
9
+ * 从 README 指定小节提取第一个 TypeScript 示例,使文档参与真实消费项目编译。
10
+ * @param section README 的三级标题文本。
11
+ * @param headingLevel 小节使用的二级或三级标题层级。
12
+ * @returns 标题下第一个 TypeScript 代码块。
13
+ * @throws 标题或代码块不存在时抛出。
14
+ */
15
+ export function readReadmeExample(section: string, headingLevel: 2 | 3 = 3): string {
16
+ const readme = readFileSync(join(packageRoot, 'README.md'), 'utf8').replaceAll('\r\n', '\n');
17
+ const heading = `${'#'.repeat(headingLevel)} ${section}\n`;
18
+ const sectionStart = readme.indexOf(heading);
19
+ if (sectionStart < 0) {
20
+ throw new Error(`Missing README section: ${section}`);
21
+ }
22
+ const sectionEnd = readme.indexOf('\n##', sectionStart + heading.length);
23
+ const content = readme.slice(sectionStart, sectionEnd < 0 ? undefined : sectionEnd);
24
+ const example = /```typescript\n([\s\S]*?)\n```/.exec(content)?.[1];
25
+ if (!example) {
26
+ throw new Error(`Missing TypeScript example in README section: ${section}`);
27
+ }
28
+ return example;
29
+ }
30
+
31
+ /** 临时消费项目的已安装产物;所有路径均位于当前测试拥有的临时目录。 */
32
+ export interface PackageConsumer {
33
+ readonly directory: string;
34
+ readonly installedPackage: string;
35
+ readonly dependencyTree: string;
36
+ }
37
+
38
+ /**
39
+ * 用结构化参数运行子进程,并清除可能让临时项目继承 workspace 依赖的环境变量。
40
+ * @param request 可执行文件、参数及工作目录。
41
+ * @returns 标准输出。
42
+ * @throws 子进程失败、超时或无法启动时抛出带输出的错误。
43
+ */
44
+ export function runCommand(request: {
45
+ readonly executable: string;
46
+ readonly args: string[];
47
+ readonly cwd: string;
48
+ }): string {
49
+ const environment = { ...process.env };
50
+ delete environment.NODE_PATH;
51
+ delete environment.INIT_CWD;
52
+ delete environment.PNPM_WORKSPACE_DIR;
53
+ const result = spawnSync(request.executable, request.args, {
54
+ cwd: request.cwd,
55
+ env: environment,
56
+ encoding: 'utf8',
57
+ timeout: 120000,
58
+ windowsHide: true,
59
+ maxBuffer: 8 * 1024 * 1024,
60
+ });
61
+ if (result.error || result.status !== 0) {
62
+ throw new Error(
63
+ `${request.executable} failed: ${result.error?.message ?? result.status}\n${result.stdout}\n${result.stderr}`
64
+ );
65
+ }
66
+ return result.stdout;
67
+ }
68
+
69
+ /** 使用生命周期提供的 pnpm 路径;Windows 独立 Jest 运行兼容 Node 安装目录中的 pnpm。 */
70
+ function runPnpm(args: string[], cwd: string): string {
71
+ const candidates = [
72
+ process.env.npm_execpath,
73
+ join(dirname(process.execPath), 'node_modules/pnpm/bin/pnpm.mjs'),
74
+ join(dirname(process.execPath), 'node_modules/pnpm/bin/pnpm.cjs'),
75
+ ];
76
+ const executable = candidates.find((candidate) => candidate && existsSync(candidate));
77
+ if (!executable) {
78
+ throw new Error('Run package consumer tests with pnpm test');
79
+ }
80
+ const isScript = ['.mjs', '.cjs', '.js'].some((extension) => executable.endsWith(extension));
81
+ return runCommand({
82
+ executable: isScript ? process.execPath : executable,
83
+ args: isScript ? [executable, ...args] : args,
84
+ cwd,
85
+ });
86
+ }
87
+
88
+ /**
89
+ * 创建与 workspace 不相交的临时测试根目录。
90
+ * @returns 位于系统临时目录且带专用前缀的目录。
91
+ */
92
+ export function createPackageTestRoot(): string {
93
+ return mkdtempSync(join(tmpdir(), 'cache-consumer-'));
94
+ }
95
+
96
+ /**
97
+ * 构建并打包当前 cache-decorator 包。
98
+ * @param root 本次测试拥有的临时根目录。
99
+ * @returns 这次测试使用的 tarball 绝对路径。
100
+ * @throws 构建或打包失败时抛出。
101
+ */
102
+ export function packCurrentPackage(root: string): string {
103
+ runCommand({
104
+ executable: process.execPath,
105
+ args: [require.resolve('typescript/bin/tsc'), '-p', join(packageRoot, 'tsconfig.json')],
106
+ cwd: packageRoot,
107
+ });
108
+ const archive = join(root, 'cache-decorator.tgz');
109
+ runPnpm(['pack', '--out', archive, '--json'], packageRoot);
110
+ return archive;
111
+ }
112
+
113
+ /**
114
+ * 构建并打包当前 workspace Logger,供外部消费 fixture 显式满足 required peer。
115
+ * @param root 本次测试拥有的临时根目录。
116
+ * @returns 这次测试使用的 Logger tarball 绝对路径。
117
+ * @throws 构建或打包失败时抛出。
118
+ */
119
+ export function packLoggerPackage(root: string): string {
120
+ const loggerRoot = resolve(packageRoot, '../logger');
121
+ runCommand({
122
+ executable: process.execPath,
123
+ args: [require.resolve('typescript/bin/tsc'), '-p', join(loggerRoot, 'tsconfig.json')],
124
+ cwd: loggerRoot,
125
+ });
126
+ const archive = join(root, 'logger.tgz');
127
+ runPnpm(['pack', '--out', archive, '--json'], loggerRoot);
128
+ return archive;
129
+ }
130
+
131
+ /** 读取当前 workspace 已安装版本,避免消费测试解析无关的新版本。 */
132
+ function installedVersion(name: string): string {
133
+ const localManifest = join(packageRoot, 'node_modules', name, 'package.json');
134
+ const manifestPath = existsSync(localManifest) ? localManifest : require.resolve(`${name}/package.json`);
135
+ const manifest: { readonly version: string } = JSON.parse(readFileSync(manifestPath, 'utf8'));
136
+ return manifest.version;
137
+ }
138
+
139
+ /**
140
+ * 安装真实 tarball 和唯一指定的 Redis 客户端,严格编译并运行消费用法。
141
+ * @param request 临时根、归档、客户端名称及消费源码。
142
+ * @returns 可审计的安装路径及生产依赖树。
143
+ * @throws 安装、严格类型检查或运行失败时抛出。
144
+ */
145
+ export function installConsumer(request: {
146
+ readonly root: string;
147
+ readonly archive: string;
148
+ readonly loggerArchive: string;
149
+ readonly client: 'none' | 'redis' | 'ioredis';
150
+ readonly source: string;
151
+ readonly readmeSource: string;
152
+ readonly quickStartSource: string;
153
+ }): PackageConsumer {
154
+ const directory = join(request.root, request.client);
155
+ mkdirSync(directory);
156
+ const dependencies: Record<string, string> = {
157
+ '@jintianxiayu/cache-decorator': `file:${request.archive.replaceAll('\\', '/')}`,
158
+ '@jintianxiayu/logger': `file:${request.loggerArchive.replaceAll('\\', '/')}`,
159
+ 'reflect-metadata': installedVersion('reflect-metadata'),
160
+ };
161
+ if (request.client !== 'none') {
162
+ dependencies[request.client] = installedVersion(request.client);
163
+ }
164
+ writeFileSync(
165
+ join(directory, 'package.json'),
166
+ JSON.stringify({
167
+ name: `cache-consumer-${request.client}`,
168
+ private: true,
169
+ dependencies,
170
+ devDependencies: {
171
+ typescript: installedVersion('typescript'),
172
+ '@types/node': installedVersion('@types/node'),
173
+ },
174
+ })
175
+ );
176
+ writeFileSync(
177
+ join(directory, 'tsconfig.json'),
178
+ JSON.stringify({
179
+ compilerOptions: {
180
+ strict: true,
181
+ skipLibCheck: false,
182
+ target: 'ES2021',
183
+ module: 'NodeNext',
184
+ moduleResolution: 'NodeNext',
185
+ types: ['node'],
186
+ outDir: 'out',
187
+ experimentalDecorators: true,
188
+ emitDecoratorMetadata: true,
189
+ },
190
+ include: ['consumer.ts', 'readme-example.ts', 'quick-start.ts'],
191
+ })
192
+ );
193
+ writeFileSync(join(directory, 'consumer.ts'), request.source);
194
+ writeFileSync(join(directory, 'readme-example.ts'), request.readmeSource);
195
+ writeFileSync(join(directory, 'quick-start.ts'), request.quickStartSource);
196
+ runPnpm(
197
+ [
198
+ 'install',
199
+ '--ignore-scripts',
200
+ '--store-dir',
201
+ join(request.root, 'store'),
202
+ '--config.auto-install-peers=false',
203
+ ],
204
+ directory
205
+ );
206
+ runCommand({
207
+ executable: process.execPath,
208
+ args: [join(directory, 'node_modules/typescript/bin/tsc'), '-p', directory],
209
+ cwd: directory,
210
+ });
211
+ runCommand({ executable: process.execPath, args: [join(directory, 'out/consumer.js')], cwd: directory });
212
+ return {
213
+ directory,
214
+ installedPackage: join(directory, 'node_modules/@jintianxiayu/cache-decorator'),
215
+ dependencyTree: runPnpm(['list', '--prod', '--depth', '100', '--json'], directory),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * 仅删除本测试通过 mkdtemp 创建且已确认位于系统临时目录内的目录。
221
+ * @param root 待删除的测试临时根目录。
222
+ * @returns 删除完成后返回。
223
+ * @throws 目标不在系统临时目录或前缀不匹配时拒绝删除。
224
+ */
225
+ export function removePackageTestRoot(root: string): void {
226
+ const absolute = resolve(root);
227
+ if (!absolute.startsWith(resolve(tmpdir()) + sep) || !basename(absolute).startsWith('cache-consumer-')) {
228
+ throw new Error('Refusing to remove a path outside the package test temporary root');
229
+ }
230
+ rmSync(absolute, { recursive: true, force: true, maxRetries: 3 });
231
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ });