@dingtalk-real-ai/dingtalk-connector 0.7.6 → 0.7.8
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/.github/workflows/issue-to-AI-table.yml +52 -0
- package/CHANGELOG.md +36 -0
- package/README.md +23 -21
- package/docs/RELEASE_NOTES_V0.7.7.md +122 -0
- package/docs/RELEASE_NOTES_V0.7.8.md +101 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +13 -5
- package/plugin.ts +210 -19
- package/tests/README.md +54 -0
- package/tests/ai-card/PLAN.md +54 -0
- package/tests/ai-card/ai-card.test.ts +372 -0
- package/tests/audio/PLAN.md +64 -0
- package/tests/audio/audio.test.ts +283 -0
- package/tests/bindings/PLAN.md +99 -0
- package/tests/bindings/bindings.test.ts +191 -0
- package/tests/card-update/PLAN.md +29 -0
- package/tests/card-update/card-update.test.ts +127 -0
- package/tests/config-token/PLAN.md +94 -0
- package/tests/config-token/config-token.test.ts +153 -0
- package/tests/core/PLAN.md +65 -0
- package/tests/core/core.test.ts +286 -0
- package/tests/deliver-payload/PLAN.md +59 -0
- package/tests/deliver-payload/deliver-payload.test.ts +91 -0
- package/tests/download/PLAN.md +47 -0
- package/tests/download/download.test.ts +261 -0
- package/tests/file-markers/PLAN.md +74 -0
- package/tests/file-markers/file-markers.test.ts +105 -0
- package/tests/index.ts +129 -0
- package/tests/integration/PLAN.md +65 -0
- package/tests/integration/integration.test.ts +232 -0
- package/tests/mcp-tools/PLAN.md +67 -0
- package/tests/mcp-tools/mcp-tools.test.ts +327 -0
- package/tests/media/PLAN.md +37 -0
- package/tests/media/media.test.ts +50 -0
- package/tests/message-extract/PLAN.md +83 -0
- package/tests/message-extract/message-extract.test.ts +205 -0
- package/tests/proactive/PLAN.md +88 -0
- package/tests/proactive/proactive.test.ts +502 -0
- package/tests/prompts/PLAN.md +71 -0
- package/tests/prompts/prompts.test.ts +64 -0
- package/tests/send-message/PLAN.md +44 -0
- package/tests/send-message/send-message.test.ts +228 -0
- package/tests/session/PLAN.md +90 -0
- package/tests/session/session.test.ts +166 -0
- package/tests/upload/PLAN.md +72 -0
- package/tests/upload/upload.test.ts +390 -0
- package/tests/video/PLAN.md +118 -0
- package/tests/video/video.test.ts +40 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock axios
|
|
4
|
+
const mockAxiosGet = vi.hoisted(() => vi.fn());
|
|
5
|
+
const mockAxiosPost = vi.hoisted(() => vi.fn());
|
|
6
|
+
vi.mock('axios', () => ({
|
|
7
|
+
default: {
|
|
8
|
+
get: mockAxiosGet,
|
|
9
|
+
post: mockAxiosPost,
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock fs
|
|
14
|
+
const mockFs = vi.hoisted(() => ({
|
|
15
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
16
|
+
mkdirSync: vi.fn(),
|
|
17
|
+
writeFileSync: vi.fn(),
|
|
18
|
+
statSync: vi.fn().mockReturnValue({ size: 1024 }),
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('fs', () => mockFs);
|
|
21
|
+
|
|
22
|
+
// Mock path and os
|
|
23
|
+
vi.mock('path', () => ({
|
|
24
|
+
join: (...args: string[]) => args.join('/'),
|
|
25
|
+
basename: (p: string) => p.split('/').pop() || '',
|
|
26
|
+
extname: (p: string) => {
|
|
27
|
+
const idx = p.lastIndexOf('.');
|
|
28
|
+
return idx >= 0 ? p.slice(idx) : '';
|
|
29
|
+
},
|
|
30
|
+
dirname: (p: string) => p.split('/').slice(0, -1).join('/'),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock('os', () => ({
|
|
34
|
+
homedir: () => '/fake-home',
|
|
35
|
+
tmpdir: () => '/tmp',
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const log = {
|
|
39
|
+
info: vi.fn(),
|
|
40
|
+
warn: vi.fn(),
|
|
41
|
+
error: vi.fn(),
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('download helpers', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('downloadImageToFile', () => {
|
|
50
|
+
it('should download image and return local path', async () => {
|
|
51
|
+
const { __testables } = await import('../../plugin');
|
|
52
|
+
const { downloadImageToFile } = __testables as any;
|
|
53
|
+
|
|
54
|
+
const mockBuffer = Buffer.from('fake-image-data');
|
|
55
|
+
mockAxiosGet.mockResolvedValue({
|
|
56
|
+
data: mockBuffer,
|
|
57
|
+
headers: { 'content-type': 'image/jpeg' },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const result = await downloadImageToFile('https://example.com/image.jpg', log);
|
|
61
|
+
|
|
62
|
+
expect(result).toMatch(/\/fake-home\/\.openclaw\/workspace\/media\/inbound\/openclaw-media-.*\.jpg/);
|
|
63
|
+
expect(log.info).toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should detect png content type', async () => {
|
|
67
|
+
const { __testables } = await import('../../plugin');
|
|
68
|
+
const { downloadImageToFile } = __testables as any;
|
|
69
|
+
|
|
70
|
+
const mockBuffer = Buffer.from('fake-png-data');
|
|
71
|
+
mockAxiosGet.mockResolvedValue({
|
|
72
|
+
data: mockBuffer,
|
|
73
|
+
headers: { 'content-type': 'image/png' },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const result = await downloadImageToFile('https://example.com/image.png', log);
|
|
77
|
+
|
|
78
|
+
expect(result).toMatch(/\.png$/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should detect gif content type', async () => {
|
|
82
|
+
const { __testables } = await import('../../plugin');
|
|
83
|
+
const { downloadImageToFile } = __testables as any;
|
|
84
|
+
|
|
85
|
+
const mockBuffer = Buffer.from('fake-gif-data');
|
|
86
|
+
mockAxiosGet.mockResolvedValue({
|
|
87
|
+
data: mockBuffer,
|
|
88
|
+
headers: { 'content-type': 'image/gif' },
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const result = await downloadImageToFile('https://example.com/image.gif', log);
|
|
92
|
+
|
|
93
|
+
expect(result).toMatch(/\.gif$/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should detect webp content type', async () => {
|
|
97
|
+
const { __testables } = await import('../../plugin');
|
|
98
|
+
const { downloadImageToFile } = __testables as any;
|
|
99
|
+
|
|
100
|
+
const mockBuffer = Buffer.from('fake-webp-data');
|
|
101
|
+
mockAxiosGet.mockResolvedValue({
|
|
102
|
+
data: mockBuffer,
|
|
103
|
+
headers: { 'content-type': 'image/webp' },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = await downloadImageToFile('https://example.com/image.webp', log);
|
|
107
|
+
|
|
108
|
+
expect(result).toMatch(/\.webp$/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should default to jpg for unknown content type', async () => {
|
|
112
|
+
const { __testables } = await import('../../plugin');
|
|
113
|
+
const { downloadImageToFile } = __testables as any;
|
|
114
|
+
|
|
115
|
+
const mockBuffer = Buffer.from('fake-data');
|
|
116
|
+
mockAxiosGet.mockResolvedValue({
|
|
117
|
+
data: mockBuffer,
|
|
118
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const result = await downloadImageToFile('https://example.com/image', log);
|
|
122
|
+
|
|
123
|
+
expect(result).toMatch(/\.jpg$/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should return null on download failure', async () => {
|
|
127
|
+
const { __testables } = await import('../../plugin');
|
|
128
|
+
const { downloadImageToFile } = __testables as any;
|
|
129
|
+
|
|
130
|
+
mockAxiosGet.mockRejectedValue(new Error('Network error'));
|
|
131
|
+
|
|
132
|
+
const result = await downloadImageToFile('https://example.com/image.jpg', log);
|
|
133
|
+
|
|
134
|
+
expect(result).toBeNull();
|
|
135
|
+
expect(log.error).toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('downloadMediaByCode', () => {
|
|
140
|
+
it('should download media using downloadCode', async () => {
|
|
141
|
+
const { __testables } = await import('../../plugin');
|
|
142
|
+
const { downloadMediaByCode } = __testables as any;
|
|
143
|
+
|
|
144
|
+
mockAxiosPost.mockResolvedValue({
|
|
145
|
+
data: { downloadUrl: 'https://example.com/download.jpg' },
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const mockBuffer = Buffer.from('fake-image-data');
|
|
149
|
+
mockAxiosGet.mockResolvedValue({
|
|
150
|
+
data: mockBuffer,
|
|
151
|
+
headers: { 'content-type': 'image/jpeg' },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
155
|
+
const result = await downloadMediaByCode('code123', config, log);
|
|
156
|
+
|
|
157
|
+
expect(result).toMatch(/\.jpg$/);
|
|
158
|
+
expect(log.info).toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should return null when no downloadUrl in response', async () => {
|
|
162
|
+
const { __testables } = await import('../../plugin');
|
|
163
|
+
const { downloadMediaByCode } = __testables as any;
|
|
164
|
+
|
|
165
|
+
mockAxiosPost.mockResolvedValue({
|
|
166
|
+
data: { errcode: 1, errmsg: 'error' },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
170
|
+
const result = await downloadMediaByCode('code123', config, log);
|
|
171
|
+
|
|
172
|
+
expect(result).toBeNull();
|
|
173
|
+
expect(log.warn).toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should return null on API error', async () => {
|
|
177
|
+
const { __testables } = await import('../../plugin');
|
|
178
|
+
const { downloadMediaByCode } = __testables as any;
|
|
179
|
+
|
|
180
|
+
mockAxiosPost.mockRejectedValue(new Error('API error'));
|
|
181
|
+
|
|
182
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
183
|
+
const result = await downloadMediaByCode('code123', config, log);
|
|
184
|
+
|
|
185
|
+
expect(result).toBeNull();
|
|
186
|
+
expect(log.error).toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('downloadFileByCode', () => {
|
|
191
|
+
it('should download file and preserve original filename', async () => {
|
|
192
|
+
const { __testables } = await import('../../plugin');
|
|
193
|
+
const { downloadFileByCode } = __testables as any;
|
|
194
|
+
|
|
195
|
+
mockAxiosPost.mockResolvedValue({
|
|
196
|
+
data: { downloadUrl: 'https://example.com/download' },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const mockBuffer = Buffer.from('fake-file-data');
|
|
200
|
+
mockAxiosGet.mockResolvedValue({
|
|
201
|
+
data: mockBuffer,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
205
|
+
const result = await downloadFileByCode('code123', 'report.pdf', config, log);
|
|
206
|
+
|
|
207
|
+
expect(result).toMatch(/report\.pdf$/);
|
|
208
|
+
expect(log.info).toHaveBeenCalled();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should sanitize filename with special characters', async () => {
|
|
212
|
+
const { __testables } = await import('../../plugin');
|
|
213
|
+
const { downloadFileByCode } = __testables as any;
|
|
214
|
+
|
|
215
|
+
mockAxiosPost.mockResolvedValue({
|
|
216
|
+
data: { downloadUrl: 'https://example.com/download' },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const mockBuffer = Buffer.from('fake-file-data');
|
|
220
|
+
mockAxiosGet.mockResolvedValue({
|
|
221
|
+
data: mockBuffer,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
225
|
+
const result = await downloadFileByCode('code123', 'file/with:invalid*chars.pdf', config, log);
|
|
226
|
+
|
|
227
|
+
// 只校验文件名本身被清理;完整路径一定会包含 '/'。
|
|
228
|
+
const fileName = result?.split('/').pop() || '';
|
|
229
|
+
expect(fileName).not.toMatch(/[\\:*?"<>|]/);
|
|
230
|
+
expect(fileName).toContain('file_with_invalid_chars');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should return null when no downloadUrl in response', async () => {
|
|
234
|
+
const { __testables } = await import('../../plugin');
|
|
235
|
+
const { downloadFileByCode } = __testables as any;
|
|
236
|
+
|
|
237
|
+
mockAxiosPost.mockResolvedValue({
|
|
238
|
+
data: {},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
242
|
+
const result = await downloadFileByCode('code123', 'file.pdf', config, log);
|
|
243
|
+
|
|
244
|
+
expect(result).toBeNull();
|
|
245
|
+
expect(log.warn).toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should return null on download failure', async () => {
|
|
249
|
+
const { __testables } = await import('../../plugin');
|
|
250
|
+
const { downloadFileByCode } = __testables as any;
|
|
251
|
+
|
|
252
|
+
mockAxiosPost.mockRejectedValue(new Error('API error'));
|
|
253
|
+
|
|
254
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
255
|
+
const result = await downloadFileByCode('code123', 'file.pdf', config, log);
|
|
256
|
+
|
|
257
|
+
expect(result).toBeNull();
|
|
258
|
+
expect(log.error).toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# 文件标记与音频类型模块测试方案
|
|
2
|
+
|
|
3
|
+
## 1. 模块划分与职责
|
|
4
|
+
|
|
5
|
+
- **extractFileMarkers(content: string, log?: any): { cleanedContent: string; fileInfos: FileInfo[] }**
|
|
6
|
+
- 职责:从 AI 回复内容中匹配 `[DINGTALK_FILE]{...}[/DINGTALK_FILE]`,解析 JSON 得到 `{ path, fileName, fileType }`,校验 path 与 fileName 存在后收集到 fileInfos,并移除标记得到 cleanedContent。
|
|
7
|
+
- **isAudioFile(fileType: string): boolean**
|
|
8
|
+
- 职责:判断扩展名是否为支持的音频类型(mp3, wav, amr, ogg, aac, flac, m4a),大小写不敏感。
|
|
9
|
+
|
|
10
|
+
## 2. 用例矩阵(全覆盖)
|
|
11
|
+
|
|
12
|
+
### 2.1 isAudioFile
|
|
13
|
+
|
|
14
|
+
| 序号 | 输入 fileType | 期望 | 说明 |
|
|
15
|
+
|------|----------------|------|------|
|
|
16
|
+
| 1 | `'mp3'` | true | 小写 |
|
|
17
|
+
| 2 | `'MP3'` | true | 大写 |
|
|
18
|
+
| 3 | `'Mp3'` | true | 混合 |
|
|
19
|
+
| 4 | `'wav'` / `'amr'` / `'ogg'` / `'aac'` / `'flac'` / `'m4a'` | true | 其余支持格式 |
|
|
20
|
+
| 5 | `'txt'` | false | 非音频 |
|
|
21
|
+
| 6 | `''` | false | 空字符串 |
|
|
22
|
+
| 7 | `'mp4'` | false | 视频格式 |
|
|
23
|
+
| 8 | `'.mp3'` | false | 带点号(代码用 includes(fileType.toLowerCase()),未 strip 点) |
|
|
24
|
+
|
|
25
|
+
### 2.2 extractFileMarkers — 无标记
|
|
26
|
+
|
|
27
|
+
| 序号 | 输入 content | 期望 cleanedContent | 期望 fileInfos | 说明 |
|
|
28
|
+
|------|--------------|---------------------|----------------|------|
|
|
29
|
+
| 9 | `''` | `''` | `[]` | 空字符串 |
|
|
30
|
+
| 10 | `'plain text'` | `'plain text'` | `[]` | 无标记 |
|
|
31
|
+
| 11 | `'[DINGTALK_FILE]'` | 原样(无闭合) | `[]` | 不匹配正则 |
|
|
32
|
+
|
|
33
|
+
### 2.3 extractFileMarkers — 合法单个标记
|
|
34
|
+
|
|
35
|
+
| 序号 | 输入 content | 期望 fileInfos[0] | 期望 cleanedContent | 说明 |
|
|
36
|
+
|------|--------------|-------------------|---------------------|------|
|
|
37
|
+
| 12 | `'hi [DINGTALK_FILE]{"path":"/tmp/a.pdf","fileName":"a.pdf","fileType":"pdf"}[/DINGTALK_FILE] end'` | path=/tmp/a.pdf, fileName=a.pdf, fileType=pdf | 'hi end'(中间标记被移除,保留空格) | 标准格式 |
|
|
38
|
+
| 13 | `'[DINGTALK_FILE]{"path":"C:\\\\x.docx","fileName":"文档.docx","fileType":"docx"}[/DINGTALK_FILE]'` | path=C:\\x.docx, fileName=文档.docx, fileType=docx | '' | 反斜杠与中文文件名 |
|
|
39
|
+
|
|
40
|
+
### 2.4 extractFileMarkers — 缺 path 或 fileName 不收集
|
|
41
|
+
|
|
42
|
+
| 序号 | 输入 content | 期望 fileInfos | 说明 |
|
|
43
|
+
|------|--------------|----------------|------|
|
|
44
|
+
| 14 | `'[DINGTALK_FILE]{"path":"/x.pdf","fileName":""}[/DINGTALK_FILE]'` | `[]` | fileName 空不满足 fileInfo.path && fileInfo.fileName |
|
|
45
|
+
| 15 | `'[DINGTALK_FILE]{"path":"","fileName":"a.pdf"}[/DINGTALK_FILE]'` | `[]` | path 空不收集 |
|
|
46
|
+
| 16 | `'[DINGTALK_FILE]{"fileName":"a.pdf"}[/DINGTALK_FILE]'` | `[]` | 缺 path |
|
|
47
|
+
| 17 | `'[DINGTALK_FILE]{"path":"/a.pdf"}[/DINGTALK_FILE]'` | `[]` | 缺 fileName |
|
|
48
|
+
|
|
49
|
+
### 2.5 extractFileMarkers — 非法 JSON
|
|
50
|
+
|
|
51
|
+
| 序号 | 输入 content | 期望 fileInfos | 期望 cleanedContent | 说明 |
|
|
52
|
+
|------|--------------|----------------|---------------------|------|
|
|
53
|
+
| 18 | `'[DINGTALK_FILE]{invalid-json}[/DINGTALK_FILE]'` | `[]` | ''(标记仍被 replace 掉) | JSON.parse 抛错,log.warn 调用 |
|
|
54
|
+
| 19 | `'[DINGTALK_FILE]{}[/DINGTALK_FILE]'` | `[]` | '' | 空对象缺 path/fileName |
|
|
55
|
+
| 20 | `'[DINGTALK_FILE]{ "path": "/x", "fileName": "x" }[/DINGTALK_FILE]'` | `[{ path: '/x', fileName: 'x', fileType: undefined }]` | '' | 合法 JSON,fileType 可缺 |
|
|
56
|
+
|
|
57
|
+
### 2.6 extractFileMarkers — 多个标记
|
|
58
|
+
|
|
59
|
+
| 序号 | 输入 content | 期望 fileInfos.length | 期望 cleanedContent | 说明 |
|
|
60
|
+
|------|--------------|------------------------|---------------------|------|
|
|
61
|
+
| 21 | 两段合法 [DINGTALK_FILE]...[/DINGTALK_FILE] | 2 | 中间文本保留、两处标记移除 | 多文件 |
|
|
62
|
+
| 22 | 一段合法 + 一段非法 JSON | 1 | 两段标记都被移除 | 混合 |
|
|
63
|
+
|
|
64
|
+
### 2.7 extractFileMarkers — 边界与 trim
|
|
65
|
+
|
|
66
|
+
| 序号 | 输入 content | 期望 cleanedContent | 说明 |
|
|
67
|
+
|------|--------------|---------------------|------|
|
|
68
|
+
| 23 | `' [DINGTALK_FILE]{"path":"/a","fileName":"a"}[/DINGTALK_FILE] '` | `''` | 前后空格被 trim |
|
|
69
|
+
| 24 | `'x[DINGTALK_FILE]{"path":"/a","fileName":"a"}[/DINGTALK_FILE]y'` | `'xy'` | 中间无空格 |
|
|
70
|
+
|
|
71
|
+
## 3. 预期正确输出与潜在错误
|
|
72
|
+
|
|
73
|
+
- **正确输出**:fileInfos 仅包含 path 与 fileName 均非空的项;cleanedContent 为移除所有 FILE_MARKER 后 trim 的结果;log.info/warn 按实现调用。
|
|
74
|
+
- **潜在错误原因**:正则贪婪/非贪婪错误导致多匹配或少匹配;JSON.parse 未 try-catch 导致整函数抛错;校验写成 path || fileName 导致只填一个也通过;replace 后未 trim。
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { __testables } from '../../plugin';
|
|
3
|
+
|
|
4
|
+
const { extractFileMarkers, isAudioFile } = __testables as any;
|
|
5
|
+
|
|
6
|
+
describe('isAudioFile', () => {
|
|
7
|
+
it.each([
|
|
8
|
+
['mp3', true],
|
|
9
|
+
['MP3', true],
|
|
10
|
+
['Mp3', true],
|
|
11
|
+
['wav', true],
|
|
12
|
+
['amr', true],
|
|
13
|
+
['ogg', true],
|
|
14
|
+
['aac', true],
|
|
15
|
+
['flac', true],
|
|
16
|
+
['m4a', true],
|
|
17
|
+
['txt', false],
|
|
18
|
+
['', false],
|
|
19
|
+
['mp4', false],
|
|
20
|
+
['pdf', false],
|
|
21
|
+
] as const)('isAudioFile(%s) === %s', (fileType, expected) => {
|
|
22
|
+
expect(isAudioFile(fileType)).toBe(expected);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('extractFileMarkers', () => {
|
|
27
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
28
|
+
|
|
29
|
+
describe('no markers', () => {
|
|
30
|
+
it.each([
|
|
31
|
+
['', '', []],
|
|
32
|
+
['plain text', 'plain text', []],
|
|
33
|
+
] as const)('content="%s" -> cleanedContent="%s", fileInfos.length=%s', (content, cleaned, len) => {
|
|
34
|
+
const out = extractFileMarkers(content, log);
|
|
35
|
+
expect(out.cleanedContent).toBe(cleaned);
|
|
36
|
+
expect(out.fileInfos).toHaveLength(len);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('valid single marker', () => {
|
|
41
|
+
it('extracts path, fileName, fileType and removes marker', () => {
|
|
42
|
+
const content = 'hi [DINGTALK_FILE]{"path":"/tmp/a.pdf","fileName":"a.pdf","fileType":"pdf"}[/DINGTALK_FILE] end';
|
|
43
|
+
const out = extractFileMarkers(content, log);
|
|
44
|
+
expect(out.fileInfos).toHaveLength(1);
|
|
45
|
+
expect(out.fileInfos[0]).toEqual({ path: '/tmp/a.pdf', fileName: 'a.pdf', fileType: 'pdf' });
|
|
46
|
+
expect(out.cleanedContent).toBe('hi end');
|
|
47
|
+
});
|
|
48
|
+
it('accepts optional fileType and unicode fileName', () => {
|
|
49
|
+
const content = '[DINGTALK_FILE]{"path":"/x.docx","fileName":"文档.docx"}[/DINGTALK_FILE]';
|
|
50
|
+
const out = extractFileMarkers(content, log);
|
|
51
|
+
expect(out.fileInfos).toHaveLength(1);
|
|
52
|
+
expect(out.fileInfos[0].path).toBe('/x.docx');
|
|
53
|
+
expect(out.fileInfos[0].fileName).toBe('文档.docx');
|
|
54
|
+
expect(out.cleanedContent).toBe('');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('missing path or fileName -> not collected', () => {
|
|
59
|
+
it.each([
|
|
60
|
+
['[DINGTALK_FILE]{"path":"/x.pdf","fileName":""}[/DINGTALK_FILE]', 0],
|
|
61
|
+
['[DINGTALK_FILE]{"path":"","fileName":"a.pdf"}[/DINGTALK_FILE]', 0],
|
|
62
|
+
['[DINGTALK_FILE]{"fileName":"a.pdf"}[/DINGTALK_FILE]', 0],
|
|
63
|
+
['[DINGTALK_FILE]{"path":"/a.pdf"}[/DINGTALK_FILE]', 0],
|
|
64
|
+
] as const)('content -> fileInfos.length=%s', (content, len) => {
|
|
65
|
+
const out = extractFileMarkers(content, log);
|
|
66
|
+
expect(out.fileInfos).toHaveLength(len);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('invalid JSON in marker', () => {
|
|
71
|
+
it('returns empty fileInfos and removes marker', () => {
|
|
72
|
+
const content = '[DINGTALK_FILE]{invalid-json}[/DINGTALK_FILE]';
|
|
73
|
+
const out = extractFileMarkers(content, log);
|
|
74
|
+
expect(out.fileInfos).toHaveLength(0);
|
|
75
|
+
expect(out.cleanedContent).toBe('');
|
|
76
|
+
expect(log.warn).toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
it('empty object -> no path/fileName', () => {
|
|
79
|
+
const content = '[DINGTALK_FILE]{}[/DINGTALK_FILE]';
|
|
80
|
+
const out = extractFileMarkers(content, log);
|
|
81
|
+
expect(out.fileInfos).toHaveLength(0);
|
|
82
|
+
expect(out.cleanedContent).toBe('');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('multiple markers', () => {
|
|
87
|
+
it('two valid markers', () => {
|
|
88
|
+
const content =
|
|
89
|
+
'a [DINGTALK_FILE]{"path":"/1.pdf","fileName":"1.pdf"}[/DINGTALK_FILE] b [DINGTALK_FILE]{"path":"/2.pdf","fileName":"2.pdf"}[/DINGTALK_FILE] c';
|
|
90
|
+
const out = extractFileMarkers(content, log);
|
|
91
|
+
expect(out.fileInfos).toHaveLength(2);
|
|
92
|
+
expect(out.fileInfos[0].fileName).toBe('1.pdf');
|
|
93
|
+
expect(out.fileInfos[1].fileName).toBe('2.pdf');
|
|
94
|
+
expect(out.cleanedContent).toBe('a b c');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('trim cleanedContent', () => {
|
|
99
|
+
it('leading/trailing spaces trimmed', () => {
|
|
100
|
+
const content = ' [DINGTALK_FILE]{"path":"/a","fileName":"a"}[/DINGTALK_FILE] ';
|
|
101
|
+
const out = extractFileMarkers(content, log);
|
|
102
|
+
expect(out.cleanedContent).toBe('');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
package/tests/index.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test entry point for DingTalk OpenClaw Connector
|
|
3
|
+
*
|
|
4
|
+
* This file exports lightweight, self-contained test utilities.
|
|
5
|
+
*
|
|
6
|
+
* Note: keep this file dependency-free (no missing re-exports), so importing it
|
|
7
|
+
* never breaks the test build.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { vi } from 'vitest';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Test configuration
|
|
14
|
+
*/
|
|
15
|
+
export const testConfig = {
|
|
16
|
+
timeout: 10000,
|
|
17
|
+
retries: 3,
|
|
18
|
+
logLevel: 'info',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a mock config for testing
|
|
23
|
+
*/
|
|
24
|
+
export function createMockConfig(overrides: Partial<{
|
|
25
|
+
clientId: string;
|
|
26
|
+
clientSecret: string;
|
|
27
|
+
webhook?: string;
|
|
28
|
+
}> = {}) {
|
|
29
|
+
return {
|
|
30
|
+
clientId: overrides.clientId || 'test-client-id',
|
|
31
|
+
clientSecret: overrides.clientSecret || 'test-client-secret',
|
|
32
|
+
webhook: overrides.webhook,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a mock logger for testing
|
|
38
|
+
*/
|
|
39
|
+
export function createMockLogger() {
|
|
40
|
+
return {
|
|
41
|
+
info: vi.fn(),
|
|
42
|
+
warn: vi.fn(),
|
|
43
|
+
error: vi.fn(),
|
|
44
|
+
debug: vi.fn(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create mock axios response
|
|
50
|
+
*/
|
|
51
|
+
export function createMockAxiosResponse(data: any, status = 200) {
|
|
52
|
+
return {
|
|
53
|
+
data,
|
|
54
|
+
status,
|
|
55
|
+
statusText: 'OK',
|
|
56
|
+
headers: {},
|
|
57
|
+
config: {},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create mock file info
|
|
63
|
+
*/
|
|
64
|
+
export function createMockFileInfo(overrides: Partial<{
|
|
65
|
+
path: string;
|
|
66
|
+
fileName: string;
|
|
67
|
+
fileType: string;
|
|
68
|
+
}> = {}) {
|
|
69
|
+
return {
|
|
70
|
+
path: overrides.path || '/tmp/test-file.pdf',
|
|
71
|
+
fileName: overrides.fileName || 'test-file.pdf',
|
|
72
|
+
fileType: overrides.fileType || 'pdf',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create mock video metadata
|
|
78
|
+
*/
|
|
79
|
+
export function createMockVideoMetadata() {
|
|
80
|
+
return {
|
|
81
|
+
duration: 60,
|
|
82
|
+
width: 1920,
|
|
83
|
+
height: 1080,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create mock target
|
|
89
|
+
*/
|
|
90
|
+
export function createMockUserTarget(userId = 'user123') {
|
|
91
|
+
return {
|
|
92
|
+
type: 'user' as const,
|
|
93
|
+
userId,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createMockGroupTarget(openConversationId = 'conv123') {
|
|
98
|
+
return {
|
|
99
|
+
type: 'group' as const,
|
|
100
|
+
openConversationId,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Wait for condition
|
|
106
|
+
*/
|
|
107
|
+
export async function waitFor(
|
|
108
|
+
condition: () => boolean,
|
|
109
|
+
timeout = 5000,
|
|
110
|
+
interval = 100
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const start = Date.now();
|
|
113
|
+
while (!condition()) {
|
|
114
|
+
if (Date.now() - start > timeout) {
|
|
115
|
+
throw new Error('Timeout waiting for condition');
|
|
116
|
+
}
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Sleep utility
|
|
123
|
+
*/
|
|
124
|
+
export function sleep(ms: number): Promise<void> {
|
|
125
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Intentionally no re-exports here: this repo doesn't maintain a shared
|
|
129
|
+
// `tests/helpers` / `tests/mocks` layer yet.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# integration(端到端链路)测试方案
|
|
2
|
+
|
|
3
|
+
## 1. 目标
|
|
4
|
+
|
|
5
|
+
该套件用于验证插件在**真实钉钉环境**下的端到端链路(鉴权、发送消息、AI Card 创建/更新/结束、媒体上传等)。它与单元测试不同:依赖真实凭证与外部网络,因此默认会被跳过。
|
|
6
|
+
|
|
7
|
+
## 2. 运行前置条件
|
|
8
|
+
|
|
9
|
+
- **环境变量**
|
|
10
|
+
- `DINGTALK_CLIENT_ID`:钉钉应用 clientId
|
|
11
|
+
- `DINGTALK_CLIENT_SECRET`:钉钉应用 clientSecret
|
|
12
|
+
- `DINGTALK_TEST_USER_ID`:用于接收消息的测试用户(可选,未设置时会用占位值导致相关用例可能失败/无效)
|
|
13
|
+
|
|
14
|
+
- **运行命令**
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm run test:integration
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
当未配置 `DINGTALK_CLIENT_ID` 或 `DINGTALK_CLIENT_SECRET` 时,测试会被 `describe.skipIf(...)` 跳过,仅保留一个“说明性测试”用于提示需要哪些环境变量(实现位于 `integration.test.ts` 顶部,通过 `plugin.__testables` 调用真实函数)。
|
|
21
|
+
|
|
22
|
+
## 3. 覆盖用例(按当前测试文件)
|
|
23
|
+
|
|
24
|
+
### 3.1 Authentication
|
|
25
|
+
|
|
26
|
+
| 序号 | 场景 | 期望 |
|
|
27
|
+
|------|------|------|
|
|
28
|
+
| 1 | 使用真实凭证获取 access token | 返回非空 token |
|
|
29
|
+
| 2 | 使用错误凭证 | promise reject |
|
|
30
|
+
|
|
31
|
+
### 3.2 Message Sending
|
|
32
|
+
|
|
33
|
+
| 序号 | 场景 | 期望 |
|
|
34
|
+
|------|------|------|
|
|
35
|
+
| 3 | 发送 text 给用户 | `ok=true` 且 `processQueryKey` 存在 |
|
|
36
|
+
| 4 | 发送 markdown 给用户 | `ok=true` |
|
|
37
|
+
| 5 | 发送 AI Card 给用户 | `ok=true` 且 `cardInstanceId` 存在 |
|
|
38
|
+
|
|
39
|
+
### 3.3 Card Operations
|
|
40
|
+
|
|
41
|
+
| 序号 | 场景 | 期望 |
|
|
42
|
+
|------|------|------|
|
|
43
|
+
| 6 | 创建 AI Card | `cardInstanceId` 存在 |
|
|
44
|
+
| 7 | stream 更新 AI Card | 返回非空结果 |
|
|
45
|
+
| 8 | finish 结束 AI Card | 返回非空结果 |
|
|
46
|
+
|
|
47
|
+
### 3.4 Media Upload
|
|
48
|
+
|
|
49
|
+
| 序号 | 场景 | 期望 | 说明 |
|
|
50
|
+
|------|------|------|------|
|
|
51
|
+
| 9 | 上传一张临时 PNG | 返回非空 mediaId | 用最小 1x1 PNG 作为测试文件 |
|
|
52
|
+
|
|
53
|
+
### 3.5 Error Handling
|
|
54
|
+
|
|
55
|
+
| 序号 | 场景 | 期望 |
|
|
56
|
+
|------|------|------|
|
|
57
|
+
| 10 | 非法 userId | 不抛异常,返回结构化结果 |
|
|
58
|
+
| 11 | 快速发送多条消息(模拟限流) | 全部 promise 完成且不抛异常 |
|
|
59
|
+
|
|
60
|
+
## 4. 风险与注意事项
|
|
61
|
+
|
|
62
|
+
- **幂等与副作用**:会真实给测试用户发消息/创建卡片,建议使用专门测试账号与群。
|
|
63
|
+
- **配额/限流**:钉钉接口可能限流;相关用例仅保证“不抛异常”,不保证每条都成功。
|
|
64
|
+
- **网络波动**:外部依赖导致偶发失败,建议在 CI 中单独分组且允许重试(如需要)。
|
|
65
|
+
|