@jintianxiayu/cache-decorator 0.1.2 → 1.0.0
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 +18 -0
- package/README.md +270 -133
- package/dist/adapters/ioredis-cache-client.d.ts +9 -0
- package/dist/adapters/ioredis-cache-client.d.ts.map +1 -0
- package/dist/adapters/ioredis-cache-client.js +73 -0
- package/dist/adapters/ioredis-cache-client.js.map +1 -0
- package/dist/adapters/node-redis-cache-client.d.ts +10 -0
- package/dist/adapters/node-redis-cache-client.d.ts.map +1 -0
- package/dist/adapters/node-redis-cache-client.js +81 -0
- package/dist/adapters/node-redis-cache-client.js.map +1 -0
- package/dist/adapters/redis-key-prefix.d.ts +22 -0
- package/dist/adapters/redis-key-prefix.d.ts.map +1 -0
- package/dist/adapters/redis-key-prefix.js +41 -0
- package/dist/adapters/redis-key-prefix.js.map +1 -0
- package/dist/core/cache-logger.d.ts +28 -0
- package/dist/core/cache-logger.d.ts.map +1 -0
- package/dist/core/cache-logger.js +70 -0
- package/dist/core/cache-logger.js.map +1 -0
- package/dist/core/cache-provider-registry.d.ts.map +1 -1
- package/dist/core/cache-provider-registry.js.map +1 -1
- package/dist/core/cache-provider.d.ts.map +1 -1
- package/dist/core/key-builder.d.ts.map +1 -1
- package/dist/core/key-builder.js +3 -2
- package/dist/core/key-builder.js.map +1 -1
- package/dist/core/native-cache.d.ts.map +1 -1
- package/dist/core/native-cache.js +2 -1
- package/dist/core/native-cache.js.map +1 -1
- package/dist/core/pending-cache.d.ts.map +1 -1
- package/dist/core/pending-cache.js +6 -3
- package/dist/core/pending-cache.js.map +1 -1
- package/dist/core/redis-cache-client.d.ts +156 -0
- package/dist/core/redis-cache-client.d.ts.map +1 -0
- package/dist/core/redis-cache-client.js +3 -0
- package/dist/core/redis-cache-client.js.map +1 -0
- package/dist/core/redis-cache.d.ts +39 -7
- package/dist/core/redis-cache.d.ts.map +1 -1
- package/dist/core/redis-cache.js +63 -22
- package/dist/core/redis-cache.js.map +1 -1
- package/dist/decorators/cache-evict.d.ts +1 -1
- package/dist/decorators/cache-evict.d.ts.map +1 -1
- package/dist/decorators/cache-evict.js +68 -14
- package/dist/decorators/cache-evict.js.map +1 -1
- package/dist/decorators/cache.d.ts +1 -1
- package/dist/decorators/cache.d.ts.map +1 -1
- package/dist/decorators/cache.js +85 -23
- package/dist/decorators/cache.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/jest.config.js +11 -14
- package/package.json +26 -29
- package/src/adapters/ioredis-cache-client.ts +89 -0
- package/src/adapters/node-redis-cache-client.ts +95 -0
- package/src/adapters/redis-key-prefix.ts +38 -0
- package/src/core/cache-logger.ts +105 -0
- package/src/core/cache-provider-registry.ts +39 -39
- package/src/core/cache-provider.ts +28 -28
- package/src/core/key-builder.ts +33 -31
- package/src/core/native-cache.ts +55 -53
- package/src/core/pending-cache.ts +29 -26
- package/src/core/redis-cache-client.ts +160 -0
- package/src/core/redis-cache.ts +104 -62
- package/src/decorators/cache-evict.ts +129 -73
- package/src/decorators/cache.ts +203 -129
- package/src/index.ts +11 -9
- package/test/cache-evict-logging.test.ts +362 -0
- package/test/cache-evict.test.ts +165 -165
- package/test/cache-logger.integration.test.ts +129 -0
- package/test/cache-logger.test.ts +153 -0
- package/test/cache-logging.test.ts +544 -0
- package/test/cache-provider-registry.test.ts +24 -24
- package/test/cache.test.ts +255 -256
- package/test/fixtures/cache-logger-child.mjs +108 -0
- package/test/helpers/legacy-redis-cache.ts +22 -0
- package/test/helpers/package-consumer.ts +231 -0
- package/test/helpers/redis-fixture.ts +142 -0
- package/test/ioredis-cache-client.test.ts +143 -0
- package/test/key-builder.test.ts +28 -28
- package/test/native-cache.test.ts +77 -71
- package/test/node-redis-cache-client.test.ts +149 -0
- package/test/pending-cache.test.ts +69 -56
- package/test/redis-cache-client-lifecycle.test.ts +112 -0
- package/test/redis-cache-client-types.test.ts +184 -0
- package/test/redis-cache-client.integration.test.ts +327 -0
- package/test/redis-cache-decorator.test.ts +201 -0
- package/test/redis-cache-package.integration.test.ts +328 -0
- package/test/redis-cache-provider.test.ts +269 -0
- package/test/type-contract/contract.ts +64 -0
- package/test/type-contract/tsconfig.json +12 -0
- package/tsconfig.json +8 -8
|
@@ -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
|
+
});
|
package/test/key-builder.test.ts
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
import { KeyBuilder } from '../src/core/key-builder';
|
|
2
2
|
|
|
3
3
|
describe('KeyBuilder', () => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
describe('build', () => {
|
|
5
|
+
it('应生成包含缓存名的 key', () => {
|
|
6
|
+
const key = KeyBuilder.build('user-cache', []);
|
|
7
|
+
expect(key).toBe('user-cache');
|
|
8
|
+
});
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
it('应生成包含参数值的 key', () => {
|
|
11
|
+
const key = KeyBuilder.build('user-cache', [1]);
|
|
12
|
+
expect(key).toBe('user-cache:1');
|
|
13
|
+
});
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
it('应处理多个参数', () => {
|
|
16
|
+
const key = KeyBuilder.build('user-cache', [1, 'test']);
|
|
17
|
+
expect(key).toBe('user-cache:1&test');
|
|
18
|
+
});
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
it('应处理对象参数', () => {
|
|
21
|
+
const key = KeyBuilder.build('user-cache', [{ id: 1, name: 'test' }]);
|
|
22
|
+
expect(key).toBe('user-cache:{"id":1,"name":"test"}');
|
|
23
|
+
});
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
it('应处理 null 和 undefined', () => {
|
|
26
|
+
const key1 = KeyBuilder.build('user-cache', [null]);
|
|
27
|
+
const key2 = KeyBuilder.build('user-cache', [undefined]);
|
|
28
|
+
expect(key1).toBe('user-cache:null');
|
|
29
|
+
expect(key2).toBe('user-cache:null');
|
|
30
|
+
});
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
it('应处理数字和布尔值', () => {
|
|
33
|
+
const key = KeyBuilder.build('user-cache', [123, true, false]);
|
|
34
|
+
expect(key).toBe('user-cache:123&true&false');
|
|
35
|
+
});
|
|
35
36
|
});
|
|
36
|
-
|
|
37
|
-
});
|
|
37
|
+
});
|