@dingtalk-real-ai/dingtalk-connector 0.7.6 → 0.7.7
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 +20 -0
- package/README.md +23 -21
- package/docs/RELEASE_NOTES_V0.7.7.md +122 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +13 -5
- package/plugin.ts +164 -16
- 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
package/tests/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# plugin 测试说明
|
|
2
|
+
|
|
3
|
+
本目录按**测试套件(模块)**划分,每个子目录包含:**测试代码**(`*.test.ts`)与**测试方案**(`PLAN.md`)。方案中给出模块职责、用例表(含输入/期望/说明)以及正确/错误输出原因,便于覆盖全部情况并扩展用例。
|
|
4
|
+
|
|
5
|
+
其中大多数为**单元测试/契约测试**(基于 mock 的纯函数与 I/O 边界验证);`integration` 为**端到端集成测试**(需要真实钉钉凭证,默认跳过)。
|
|
6
|
+
|
|
7
|
+
## 模块列表
|
|
8
|
+
|
|
9
|
+
| 目录 | 覆盖函数/能力 | 说明 |
|
|
10
|
+
|------|----------------|------|
|
|
11
|
+
| [session](./session/) | normalizeSlashCommand, buildSessionContext, isMessageProcessed, markMessageProcessed, cleanupProcessedMessages | 会话与消息去重 |
|
|
12
|
+
| [config-token](./config-token/) | getConfig, isConfigured, getAccessToken, getOapiAccessToken, getUnionId | 配置与 Token |
|
|
13
|
+
| [media](./media/) | toLocalPath, processLocalImages | 本地路径与图片后处理 |
|
|
14
|
+
| [video](./video/) | processVideoMarkers | 视频标记解析与控制流 |
|
|
15
|
+
| [message-extract](./message-extract/) | extractMessageContent | 钉钉消息内容提取(text/richText/picture/audio/video/file) |
|
|
16
|
+
| [file-markers](./file-markers/) | extractFileMarkers, isAudioFile | 文件标记解析与音频类型判断 |
|
|
17
|
+
| [prompts](./prompts/) | buildMediaSystemPrompt | 媒体相关系统提示词 |
|
|
18
|
+
| [deliver-payload](./deliver-payload/) | buildDeliverBody, buildMsgPayload | AI Card 投放体与普通消息体 |
|
|
19
|
+
| [bindings](./bindings/) | resolveAgentIdByBindings | OpenClaw bindings 解析(需 mock fs/path/os) |
|
|
20
|
+
| [ai-card](./ai-card/) | create/stream/finish AI Card, sendAICard* | AI Card 创建与流式更新(结构化返回 + 回退) |
|
|
21
|
+
| [card-update](./card-update/) | createAICard + stream/finish 状态机(回归集) | 轻量回归:目标选择与 INPUTING→streaming→FINISHED 状态机(避免与 ai-card/proactive 重复) |
|
|
22
|
+
| [send-message](./send-message/) | sendTextMessage, sendMarkdownMessage, sendMessage | 群机器人 webhook 发送(自动 text/markdown 判定) |
|
|
23
|
+
| [proactive](./proactive/) | buildMsgPayload, sendNormalTo*, sendTo*, sendProactive | 主动消息 API:消息体构造、AI Card 策略与回退 |
|
|
24
|
+
| [audio](./audio/) | isAudioFile, getFfprobePath, extractAudioDuration, processAudioMarkers, sendAudio* | 音频识别、时长解析与音频发送 |
|
|
25
|
+
| [upload](./upload/) | uploadMediaToDingTalk, download*/extractVideo*, process*Markers | 上传/下载/媒体处理与 marker 处理(多分支容错) |
|
|
26
|
+
| [download](./download/) | downloadImageToFile, downloadMediaByCode, downloadFileByCode | 下载与文件名/扩展名推断 |
|
|
27
|
+
| [mcp-tools](./mcp-tools/) | MCP 工具层:sendToUser/sendToGroup/sendProactive/uploadMedia... | 对外工具输入校验与行为一致性 |
|
|
28
|
+
| [core](./core/) | normalizeSlashCommand/getConfig/getAccessToken/... | 核心能力冒烟/回归集合(跨模块快速验证) |
|
|
29
|
+
| [integration](./integration/) | getAccessToken, sendToUser, AI Card, media upload | 端到端集成测试(需要真实环境变量) |
|
|
30
|
+
|
|
31
|
+
## 运行方式
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm test # 运行全部用例
|
|
35
|
+
npm run test:watch # 监听模式
|
|
36
|
+
npm run test:integration # 仅运行集成测试(需要环境变量)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 扩展用例
|
|
40
|
+
|
|
41
|
+
- 各 `PLAN.md` 中均有用例表(序号 + 输入 + 期望 + 说明),可按表在对应 `*.test.ts` 中用 `it.each` 或单条 `it` 补充,实现“成百上千条”覆盖。
|
|
42
|
+
- 依赖真实外部环境(如钉钉 API、真实文件系统、ffmpeg/ffprobe)的路径,优先在单元测试中做 **mock + 契约断言**;需要验证真实链路时再放到 `integration`(或后续 E2E)中补充。
|
|
43
|
+
|
|
44
|
+
## 测试编写约定(建议遵循)
|
|
45
|
+
|
|
46
|
+
- **模块职责优先**:每个 `tests/<suite>/` 只覆盖该域内的能力;不要在一个套件里测试“顺手能调到”的其它函数(避免重复与耦合)。
|
|
47
|
+
- **断言优先级**:
|
|
48
|
+
- **优先断言契约**:关键字段(如 `msgKey/msgParam/openSpaceId`)与错误返回结构(如 `ok=false` 时 `error`)。
|
|
49
|
+
- **避免断言实现细节**:例如随机生成的 id、时间戳、日志完整文本等。
|
|
50
|
+
- **Mock 外部依赖**:
|
|
51
|
+
- 网络:优先 `vi.mock('axios')` + hoisted mock,并按 URL 分流(token 获取与业务请求常共用 `axios.post`)。
|
|
52
|
+
- 文件系统:尽量用 `await import('fs')` 的代码路径以便在测试中 `vi.mock('fs')/vi.doMock('fs')` 生效。
|
|
53
|
+
- 外部工具(ffmpeg/ffprobe):测试中不依赖真实二进制与文件存在性;失败路径应可预测(返回 `null` 或默认值),必要时用集成测试覆盖真实链路。
|
|
54
|
+
- **避免共享状态污染**:若被测模块存在模块级缓存(如 token 缓存),在需要隔离的用例中使用 `vi.resetModules()` 后再 `import`。
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# AI Card 模块测试方案
|
|
2
|
+
|
|
3
|
+
## 1. 模块划分与职责
|
|
4
|
+
|
|
5
|
+
本套件覆盖 AI Card 相关能力(创建/流式更新/结束/投放与回退),主要围绕以下通过 `plugin.__testables` 暴露的函数:
|
|
6
|
+
|
|
7
|
+
- **buildDeliverBody(cardInstanceId, target, robotCode)**:构建投放请求体(核心契约:`openSpaceId` 与 deliver model)。
|
|
8
|
+
- **createAICardForTarget(config, target, log?)**:通用创建+投放,返回 `{ cardInstanceId, accessToken, inputingStarted }` 或 null。
|
|
9
|
+
- **createAICard(config, data, log?)**:被动回复场景创建卡片(从 DingTalk 回调 `data` 推导 target 后委托给 createAICardForTarget)。
|
|
10
|
+
- **streamAICard(card, content, finished?, log?)**:流式更新(首次会切换 INPUTING;失败会 throw)。
|
|
11
|
+
- **finishAICard(card, content, log?)**:结束卡片(finalize streaming + 设置 FINISHED;FINISHED 写入失败会记录日志但不抛异常)。
|
|
12
|
+
- **sendAICardInternal / sendAICardToUser / sendAICardToGroup**:主动发送 AI Card(失败可按策略回退到普通消息)。
|
|
13
|
+
|
|
14
|
+
## 2. 用例表(覆盖现有测试)
|
|
15
|
+
|
|
16
|
+
### 2.1 buildDeliverBody
|
|
17
|
+
|
|
18
|
+
| 序号 | 场景 | mock/输入 | 期望 | 说明 |
|
|
19
|
+
|------|------|-----------|------|------|
|
|
20
|
+
| 1 | user target | target={type:'user', userId:'u1'} | openSpaceId 为 `dtv1.card//IM_ROBOT.u1` | deliver model 正确 |
|
|
21
|
+
| 2 | group target | target={type:'group', openConversationId:'c1'} | openSpaceId 为 `dtv1.card//IM_GROUP.c1` | deliver model 正确 |
|
|
22
|
+
|
|
23
|
+
### 2.2 createAICardForTarget
|
|
24
|
+
|
|
25
|
+
| 序号 | 场景 | mock/输入 | 期望 | 说明 |
|
|
26
|
+
|------|------|-----------|------|------|
|
|
27
|
+
| 3 | user 创建投放成功 | POST instances + deliver 成功 | 返回 cardInstanceId/accessToken | 并投放到 IM_ROBOT |
|
|
28
|
+
| 4 | group 创建投放成功 | 同上 | 返回非空 | 并投放到 IM_GROUP |
|
|
29
|
+
| 5 | create 失败 | axios.post reject | 返回 null | 错误收敛 |
|
|
30
|
+
| 6 | deliver 失败 | deliver reject | 返回 null | 错误收敛 |
|
|
31
|
+
|
|
32
|
+
### 2.3 streamAICard / finishAICard
|
|
33
|
+
|
|
34
|
+
| 序号 | 场景 | 输入 | 期望 | 说明 |
|
|
35
|
+
|------|------|------|------|------|
|
|
36
|
+
| 7 | 首次 stream | inputingStarted=false | 先 INPUTING 再 streaming | INPUTING 失败会 throw |
|
|
37
|
+
| 8 | 后续 stream | inputingStarted=true | 仅 streaming | 不重复 INPUTING |
|
|
38
|
+
| 9 | finish | - | finalize streaming + FINISHED | FINISHED 失败记录 error 不 throw |
|
|
39
|
+
|
|
40
|
+
### 2.4 sendAICardInternal / sendAICardToUser / sendAICardToGroup
|
|
41
|
+
|
|
42
|
+
| 序号 | 场景 | mock/输入 | 期望 | 说明 |
|
|
43
|
+
|------|------|-----------|------|------|
|
|
44
|
+
| 10 | internal 用户发送成功 | gettoken + create + put 成功 | `ok=true` | 主流程 |
|
|
45
|
+
| 11 | internal 群发送成功 | 同上 | `ok=true` | 主流程 |
|
|
46
|
+
| 12 | internal 创建失败 | create 抛错 | `ok=false` + `error` | 错误返回 |
|
|
47
|
+
| 13 | toUser 带回退 | 卡片失败、普通消息成功(若实现有回退) | `ok=true` | 体现“回退不影响 ok” |
|
|
48
|
+
| 14 | toGroup 带回退 | 同上 | `ok=true` | |
|
|
49
|
+
|
|
50
|
+
## 3. 预期正确输出与潜在错误
|
|
51
|
+
|
|
52
|
+
- **正确**:deliver body 的 `openSpaceId/robotCode` 契约稳定;createAICardForTarget 返回结构稳定;stream/finish 状态机顺序正确;外部错误被收敛为 null/throw(按函数契约)。
|
|
53
|
+
- **潜在错误原因**:openSpaceId 拼错(IM_ROBOT/IM_GROUP);deliver model 字段缺失;stream 首次未切 INPUTING;把应 throw 的路径吞掉导致上层误判成功。
|
|
54
|
+
|
|
@@ -0,0 +1,372 @@
|
|
|
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
|
+
const mockAxiosPut = vi.hoisted(() => vi.fn());
|
|
7
|
+
vi.mock('axios', () => ({
|
|
8
|
+
default: {
|
|
9
|
+
get: mockAxiosGet,
|
|
10
|
+
post: mockAxiosPost,
|
|
11
|
+
put: mockAxiosPut,
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// Mock fs
|
|
16
|
+
vi.mock('fs', () => ({
|
|
17
|
+
existsSync: vi.fn().mockReturnValue(true),
|
|
18
|
+
mkdirSync: vi.fn(),
|
|
19
|
+
writeFileSync: vi.fn(),
|
|
20
|
+
statSync: vi.fn().mockReturnValue({ size: 1024 }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
// Mock path and os
|
|
24
|
+
vi.mock('path', () => ({
|
|
25
|
+
join: (...args: string[]) => args.join('/'),
|
|
26
|
+
basename: (p: string) => p.split('/').pop() || '',
|
|
27
|
+
dirname: (p: string) => p.split('/').slice(0, -1).join('/'),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('os', () => ({
|
|
31
|
+
homedir: () => '/fake-home',
|
|
32
|
+
tmpdir: () => '/tmp',
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const log = {
|
|
36
|
+
info: vi.fn(),
|
|
37
|
+
warn: vi.fn(),
|
|
38
|
+
error: vi.fn(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('AI Card helpers', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('buildDeliverBody', () => {
|
|
47
|
+
it('should build deliver body for user target', async () => {
|
|
48
|
+
const { __testables } = await import('../../plugin');
|
|
49
|
+
const { buildDeliverBody } = __testables as any;
|
|
50
|
+
|
|
51
|
+
const result = buildDeliverBody('card123', { type: 'user', userId: 'user123' }, 'robotCode');
|
|
52
|
+
|
|
53
|
+
expect(result.outTrackId).toBe('card123');
|
|
54
|
+
expect(result.userIdType).toBe(1);
|
|
55
|
+
expect(result.openSpaceId).toBe('dtv1.card//IM_ROBOT.user123');
|
|
56
|
+
expect(result.imRobotOpenDeliverModel).toBeDefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should build deliver body for group target', async () => {
|
|
60
|
+
const { __testables } = await import('../../plugin');
|
|
61
|
+
const { buildDeliverBody } = __testables as any;
|
|
62
|
+
|
|
63
|
+
const result = buildDeliverBody('card123', { type: 'group', openConversationId: 'conv123' }, 'robotCode');
|
|
64
|
+
|
|
65
|
+
expect(result.outTrackId).toBe('card123');
|
|
66
|
+
expect(result.openSpaceId).toBe('dtv1.card//IM_GROUP.conv123');
|
|
67
|
+
expect(result.imGroupOpenDeliverModel).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('createAICardForTarget', () => {
|
|
72
|
+
it('should create AI card for user successfully', async () => {
|
|
73
|
+
const { __testables } = await import('../../plugin');
|
|
74
|
+
const { createAICardForTarget } = __testables as any;
|
|
75
|
+
|
|
76
|
+
mockAxiosPost.mockImplementation((url: string) => {
|
|
77
|
+
if (url === 'https://api.dingtalk.com/v1.0/oauth2/accessToken') {
|
|
78
|
+
return Promise.resolve({ data: { accessToken: 'token123', expireIn: 7200 } });
|
|
79
|
+
}
|
|
80
|
+
if (url.includes('/card/instances')) {
|
|
81
|
+
return Promise.resolve({ status: 200, data: {} });
|
|
82
|
+
}
|
|
83
|
+
if (url.includes('/deliver')) {
|
|
84
|
+
return Promise.resolve({ status: 200, data: {} });
|
|
85
|
+
}
|
|
86
|
+
return Promise.resolve({ status: 200, data: {} });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
90
|
+
const target = { type: 'user' as const, userId: 'user123' };
|
|
91
|
+
|
|
92
|
+
const result = await createAICardForTarget(config, target, log);
|
|
93
|
+
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
expect(result?.cardInstanceId).toMatch(/^card_/);
|
|
96
|
+
expect(result?.accessToken).toBe('token123');
|
|
97
|
+
|
|
98
|
+
// 契约断言:应投放到 IM_ROBOT.user123,且 robotCode 为 config.clientId
|
|
99
|
+
const deliverCall = mockAxiosPost.mock.calls.find((c) =>
|
|
100
|
+
String(c[0]).includes('/v1.0/card/instances/deliver')
|
|
101
|
+
);
|
|
102
|
+
expect(deliverCall).toBeDefined();
|
|
103
|
+
const deliverBody = deliverCall![1];
|
|
104
|
+
expect(deliverBody.openSpaceId).toBe('dtv1.card//IM_ROBOT.user123');
|
|
105
|
+
expect(deliverBody.imRobotOpenDeliverModel?.robotCode).toBe('test');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should create AI card for group successfully', async () => {
|
|
109
|
+
const { __testables } = await import('../../plugin');
|
|
110
|
+
const { createAICardForTarget } = __testables as any;
|
|
111
|
+
|
|
112
|
+
mockAxiosPost.mockImplementation((url: string) => {
|
|
113
|
+
if (url === 'https://api.dingtalk.com/v1.0/oauth2/accessToken') {
|
|
114
|
+
return Promise.resolve({ data: { accessToken: 'token123', expireIn: 7200 } });
|
|
115
|
+
}
|
|
116
|
+
return Promise.resolve({ status: 200, data: {} });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
120
|
+
const target = { type: 'group' as const, openConversationId: 'conv123' };
|
|
121
|
+
|
|
122
|
+
const result = await createAICardForTarget(config, target, log);
|
|
123
|
+
|
|
124
|
+
expect(result).not.toBeNull();
|
|
125
|
+
|
|
126
|
+
// 契约断言:应投放到 IM_GROUP.conv123
|
|
127
|
+
const deliverCall = mockAxiosPost.mock.calls.find((c) =>
|
|
128
|
+
String(c[0]).includes('/v1.0/card/instances/deliver')
|
|
129
|
+
);
|
|
130
|
+
expect(deliverCall).toBeDefined();
|
|
131
|
+
const deliverBody = deliverCall![1];
|
|
132
|
+
expect(deliverBody.openSpaceId).toBe('dtv1.card//IM_GROUP.conv123');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should return null on create card failure', async () => {
|
|
136
|
+
const { __testables } = await import('../../plugin');
|
|
137
|
+
const { createAICardForTarget } = __testables as any;
|
|
138
|
+
|
|
139
|
+
mockAxiosPost.mockRejectedValue(new Error('API error'));
|
|
140
|
+
|
|
141
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
142
|
+
const target = { type: 'user' as const, userId: 'user123' };
|
|
143
|
+
|
|
144
|
+
const result = await createAICardForTarget(config, target, log);
|
|
145
|
+
|
|
146
|
+
expect(result).toBeNull();
|
|
147
|
+
expect(log.error).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should return null on deliver card failure', async () => {
|
|
151
|
+
const { __testables } = await import('../../plugin');
|
|
152
|
+
const { createAICardForTarget } = __testables as any;
|
|
153
|
+
|
|
154
|
+
mockAxiosPost.mockImplementation((url: string) => {
|
|
155
|
+
if (url.includes('/card/instances') && !url.includes('/deliver')) {
|
|
156
|
+
return Promise.resolve({ status: 200, data: {} });
|
|
157
|
+
}
|
|
158
|
+
return Promise.reject(new Error('Deliver failed'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
mockAxiosGet.mockResolvedValue({ data: { errcode: 0, access_token: 'token123' } });
|
|
162
|
+
|
|
163
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
164
|
+
const target = { type: 'user' as const, userId: 'user123' };
|
|
165
|
+
|
|
166
|
+
const result = await createAICardForTarget(config, target, log);
|
|
167
|
+
|
|
168
|
+
expect(result).toBeNull();
|
|
169
|
+
expect(log.error).toHaveBeenCalled();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('streamAICard', () => {
|
|
174
|
+
it('should switch to INPUTING status on first call', async () => {
|
|
175
|
+
const { __testables } = await import('../../plugin');
|
|
176
|
+
const { streamAICard } = __testables as any;
|
|
177
|
+
|
|
178
|
+
mockAxiosPut.mockResolvedValue({ status: 200, data: {} });
|
|
179
|
+
|
|
180
|
+
const card = { cardInstanceId: 'card123', accessToken: 'token123', inputingStarted: false };
|
|
181
|
+
|
|
182
|
+
await streamAICard(card, 'Hello', false, log);
|
|
183
|
+
|
|
184
|
+
// Should have called INPUTING status update first
|
|
185
|
+
expect(mockAxiosPut).toHaveBeenCalled();
|
|
186
|
+
expect(card.inputingStarted).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should not switch to INPUTING on subsequent calls', async () => {
|
|
190
|
+
const { __testables } = await import('../../plugin');
|
|
191
|
+
const { streamAICard } = __testables as any;
|
|
192
|
+
|
|
193
|
+
mockAxiosPut.mockResolvedValue({ status: 200, data: {} });
|
|
194
|
+
|
|
195
|
+
const card = { cardInstanceId: 'card123', accessToken: 'token123', inputingStarted: true };
|
|
196
|
+
|
|
197
|
+
await streamAICard(card, 'Hello more', false, log);
|
|
198
|
+
|
|
199
|
+
// Should not have called INPUTING status update
|
|
200
|
+
const calls = mockAxiosPut.mock.calls;
|
|
201
|
+
const inputingCalls = calls.filter((c: any[]) => c[0].includes('/card/instances'));
|
|
202
|
+
// Only streaming call, no status call
|
|
203
|
+
expect(inputingCalls.length).toBe(0);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should throw on INPUTING failure', async () => {
|
|
207
|
+
const { __testables } = await import('../../plugin');
|
|
208
|
+
const { streamAICard } = __testables as any;
|
|
209
|
+
|
|
210
|
+
mockAxiosPut.mockRejectedValue(new Error('Status update failed'));
|
|
211
|
+
|
|
212
|
+
const card = { cardInstanceId: 'card123', accessToken: 'token123', inputingStarted: false };
|
|
213
|
+
|
|
214
|
+
await expect(streamAICard(card, 'Hello', false, log)).rejects.toThrow();
|
|
215
|
+
expect(log.error).toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should handle streaming failure', async () => {
|
|
219
|
+
const { __testables } = await import('../../plugin');
|
|
220
|
+
const { streamAICard } = __testables as any;
|
|
221
|
+
|
|
222
|
+
mockAxiosPut.mockImplementation((url: string) => {
|
|
223
|
+
if (url.includes('/card/instances')) {
|
|
224
|
+
return Promise.resolve({ status: 200, data: {} });
|
|
225
|
+
}
|
|
226
|
+
return Promise.reject(new Error('Streaming failed'));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const card = { cardInstanceId: 'card123', accessToken: 'token123', inputingStarted: true };
|
|
230
|
+
|
|
231
|
+
await expect(streamAICard(card, 'Hello', false, log)).rejects.toThrow();
|
|
232
|
+
expect(log.error).toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('finishAICard', () => {
|
|
237
|
+
it('should finalize card with content', async () => {
|
|
238
|
+
const { __testables } = await import('../../plugin');
|
|
239
|
+
const { finishAICard } = __testables as any;
|
|
240
|
+
|
|
241
|
+
mockAxiosPut.mockResolvedValue({ status: 200, data: {} });
|
|
242
|
+
|
|
243
|
+
const card = { cardInstanceId: 'card123', accessToken: 'token123', inputingStarted: true };
|
|
244
|
+
|
|
245
|
+
await finishAICard(card, 'Final content', log);
|
|
246
|
+
|
|
247
|
+
expect(log.info).toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should handle finish failure gracefully', async () => {
|
|
251
|
+
const { __testables } = await import('../../plugin');
|
|
252
|
+
const { finishAICard } = __testables as any;
|
|
253
|
+
|
|
254
|
+
mockAxiosPut.mockImplementation((url: string) => {
|
|
255
|
+
if (url.includes('/streaming')) {
|
|
256
|
+
return Promise.resolve({ status: 200 });
|
|
257
|
+
}
|
|
258
|
+
return Promise.reject(new Error('Finish failed'));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const card = { cardInstanceId: 'card123', accessToken: 'token123', inputingStarted: true };
|
|
262
|
+
|
|
263
|
+
// Should not throw, just log error
|
|
264
|
+
await finishAICard(card, 'Final content', log);
|
|
265
|
+
|
|
266
|
+
expect(log.error).toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('sendAICardToUser', () => {
|
|
271
|
+
it('should send AI card to user successfully', async () => {
|
|
272
|
+
const { __testables } = await import('../../plugin');
|
|
273
|
+
const { sendAICardToUser } = __testables as any;
|
|
274
|
+
|
|
275
|
+
mockAxiosPost.mockResolvedValue({ status: 200, data: {} });
|
|
276
|
+
mockAxiosGet.mockResolvedValue({ data: { errcode: 0, access_token: 'token123' } });
|
|
277
|
+
mockAxiosPut.mockResolvedValue({ status: 200, data: {} });
|
|
278
|
+
|
|
279
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
280
|
+
|
|
281
|
+
const result = await sendAICardToUser(config, 'user123', 'Hello', log);
|
|
282
|
+
|
|
283
|
+
expect(result.ok).toBe(true);
|
|
284
|
+
expect(result.usedAICard).toBe(true);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should return error when card creation fails', async () => {
|
|
288
|
+
const { __testables } = await import('../../plugin');
|
|
289
|
+
const { sendAICardToUser } = __testables as any;
|
|
290
|
+
|
|
291
|
+
mockAxiosPost.mockRejectedValue(new Error('API error'));
|
|
292
|
+
|
|
293
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
294
|
+
|
|
295
|
+
const result = await sendAICardToUser(config, 'user123', 'Hello', log);
|
|
296
|
+
|
|
297
|
+
expect(result.ok).toBe(false);
|
|
298
|
+
expect(result.error).toBeDefined();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('sendAICardToGroup', () => {
|
|
303
|
+
it('should send AI card to group successfully', async () => {
|
|
304
|
+
const { __testables } = await import('../../plugin');
|
|
305
|
+
const { sendAICardToGroup } = __testables as any;
|
|
306
|
+
|
|
307
|
+
mockAxiosPost.mockResolvedValue({ status: 200, data: {} });
|
|
308
|
+
mockAxiosGet.mockResolvedValue({ data: { errcode: 0, access_token: 'token123' } });
|
|
309
|
+
mockAxiosPut.mockResolvedValue({ status: 200, data: {} });
|
|
310
|
+
|
|
311
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
312
|
+
|
|
313
|
+
const result = await sendAICardToGroup(config, 'conv123', 'Hello group', log);
|
|
314
|
+
|
|
315
|
+
expect(result.ok).toBe(true);
|
|
316
|
+
expect(result.usedAICard).toBe(true);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should return error when card creation fails', async () => {
|
|
320
|
+
const { __testables } = await import('../../plugin');
|
|
321
|
+
const { sendAICardToGroup } = __testables as any;
|
|
322
|
+
|
|
323
|
+
mockAxiosPost.mockRejectedValue(new Error('API error'));
|
|
324
|
+
|
|
325
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
326
|
+
|
|
327
|
+
const result = await sendAICardToGroup(config, 'conv123', 'Hello group', log);
|
|
328
|
+
|
|
329
|
+
expect(result.ok).toBe(false);
|
|
330
|
+
expect(result.error).toBeDefined();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('sendAICardInternal', () => {
|
|
335
|
+
it('should return ok with usedAICard false when content is empty after processing', async () => {
|
|
336
|
+
const { __testables } = await import('../../plugin');
|
|
337
|
+
const { sendAICardInternal } = __testables as any;
|
|
338
|
+
|
|
339
|
+
mockAxiosGet.mockResolvedValue({ data: { errcode: 0, access_token: 'token123' } });
|
|
340
|
+
|
|
341
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
342
|
+
const target = { type: 'user' as const, userId: 'user123' };
|
|
343
|
+
|
|
344
|
+
// Content that would be processed to empty
|
|
345
|
+
const result = await sendAICardInternal(config, target, '', log);
|
|
346
|
+
|
|
347
|
+
expect(result.ok).toBe(true);
|
|
348
|
+
expect(result.usedAICard).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should process local images when oapiToken is available', async () => {
|
|
352
|
+
const { __testables } = await import('../../plugin');
|
|
353
|
+
const { sendAICardInternal } = __testables as any;
|
|
354
|
+
|
|
355
|
+
mockAxiosGet.mockImplementation((url: string) => {
|
|
356
|
+
if (url.includes('gettoken')) {
|
|
357
|
+
return Promise.resolve({ data: { errcode: 0, access_token: 'oapi-token' } });
|
|
358
|
+
}
|
|
359
|
+
return Promise.resolve({ data: { errcode: 0, access_token: 'token123' } });
|
|
360
|
+
});
|
|
361
|
+
mockAxiosPost.mockResolvedValue({ status: 200, data: {} });
|
|
362
|
+
mockAxiosPut.mockResolvedValue({ status: 200, data: {} });
|
|
363
|
+
|
|
364
|
+
const config = { clientId: 'test', clientSecret: 'secret' };
|
|
365
|
+
const target = { type: 'user' as const, userId: 'user123' };
|
|
366
|
+
|
|
367
|
+
const result = await sendAICardInternal(config, target, 'Hello with ', log);
|
|
368
|
+
|
|
369
|
+
expect(result.ok).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Audio 模块测试方案
|
|
2
|
+
|
|
3
|
+
## 1. 模块划分与职责
|
|
4
|
+
|
|
5
|
+
本套件覆盖音频相关的工具函数与发送逻辑:
|
|
6
|
+
|
|
7
|
+
- **isAudioFile(ext: string): boolean**:判断扩展名是否属于音频类型(大小写不敏感)。
|
|
8
|
+
- **getFfprobePath(): string**:优先使用 `@ffprobe-installer/ffprobe` 提供的路径;若不可用则读取 `FFPROBE_PATH`;再否则回退到 `'ffprobe'`。
|
|
9
|
+
- **extractAudioDuration(filePath: string, log): Promise<number \| null>**:通过 ffprobe 解析音频时长(秒)并转换为毫秒;异常/解析失败返回 null。
|
|
10
|
+
- **processAudioMarkers(content, accessToken, config, oapiToken, log)**:处理内容中的音频标记;缺 `oapiToken` 时原样返回并告警;标记 JSON 非法则移除并告警;文件不存在时给出提示并清理标记。
|
|
11
|
+
- **sendAudioMessage(...) / sendAudioProactive(...)**:发送音频消息(普通 webhook / 主动消息 API),支持 duration 传入与默认值。
|
|
12
|
+
|
|
13
|
+
## 2. 用例表(覆盖现有测试)
|
|
14
|
+
|
|
15
|
+
### 2.1 isAudioFile
|
|
16
|
+
|
|
17
|
+
| 序号 | 输入 | 期望 | 说明 |
|
|
18
|
+
|------|------|------|------|
|
|
19
|
+
| 1 | `mp3/wav/amr/ogg/aac/flac/m4a` | true | 音频扩展名 |
|
|
20
|
+
| 2 | 大写扩展名(如 `MP3`) | true | 大小写不敏感 |
|
|
21
|
+
| 3 | `mp4/txt/pdf/png/jpg/''` | false | 非音频 |
|
|
22
|
+
|
|
23
|
+
### 2.2 getFfprobePath
|
|
24
|
+
|
|
25
|
+
| 序号 | 场景 | 输入/环境 | 期望 | 说明 |
|
|
26
|
+
|------|------|-----------|------|------|
|
|
27
|
+
| 4 | env 有值 | `FFPROBE_PATH=/custom/ffprobe` | 返回 env | 兼容 CI/本地自定义 |
|
|
28
|
+
| 5 | env 无值且包不可用 | 无 env | 返回 `'ffprobe'` | 最终回退(走系统 PATH) |
|
|
29
|
+
|
|
30
|
+
### 2.3 extractAudioDuration
|
|
31
|
+
|
|
32
|
+
| 序号 | 场景 | execFile 输出 | 期望 | 说明 |
|
|
33
|
+
|------|------|---------------|------|------|
|
|
34
|
+
| 7 | 成功 | JSON `{ format: { duration: '123.45' } }` | `123450` | 秒→毫秒 |
|
|
35
|
+
| 8 | execFile 失败 | callback(err) | null | 记录 error |
|
|
36
|
+
| 9 | JSON 解析失败 | 输出非 JSON | null | 记录 error |
|
|
37
|
+
| 10 | duration 非数字 | `'not-a-number'` | null | 记录 warn |
|
|
38
|
+
| 11 | format 缺失 | `{}` | null | 记录 warn |
|
|
39
|
+
|
|
40
|
+
### 2.4 processAudioMarkers
|
|
41
|
+
|
|
42
|
+
| 序号 | 场景 | 输入 | 期望 | 说明 |
|
|
43
|
+
|------|------|------|------|------|
|
|
44
|
+
| 12 | oapiToken 缺失 | `oapiToken=null` + 含标记文本 | 原样返回 | 并 warn |
|
|
45
|
+
| 13 | 无标记 | 普通文本 | 原样返回 | |
|
|
46
|
+
| 14 | 标记 JSON 非法 | `...{invalid-json}...` | 清理标记、保留其余文本 | warn |
|
|
47
|
+
| 15 | 文件不存在 | marker path 不存在 | 结果包含提示符号(如 `⚠️`) | 并清理标记 |
|
|
48
|
+
|
|
49
|
+
### 2.5 sendAudioMessage / sendAudioProactive
|
|
50
|
+
|
|
51
|
+
| 序号 | 场景 | 输入 | 期望 | 说明 |
|
|
52
|
+
|------|------|------|------|------|
|
|
53
|
+
| 16 | sendAudioMessage 默认 duration | 不传 duration | 不抛错并 info | 默认时长 |
|
|
54
|
+
| 17 | sendAudioMessage 指定 duration | duration=30000 | 不抛错并 info | 透传 |
|
|
55
|
+
| 18 | sendAudioMessage 失败 | axios.post 抛错 | 不抛错并 error | 错误收敛 |
|
|
56
|
+
| 19 | proactive user | target=user | 不抛错并 info | 主动消息 |
|
|
57
|
+
| 20 | proactive group | target=group | 不抛错并 info | |
|
|
58
|
+
| 21 | proactive duration 透传 | duration=45000 | msgParam.duration === '45000' | 字符串化 |
|
|
59
|
+
|
|
60
|
+
## 3. 预期正确输出与潜在错误
|
|
61
|
+
|
|
62
|
+
- **正确**:音频类型识别覆盖常见扩展名;ffprobe 路径优先级明确且可在 CI 环境运行;时长解析异常均返回 null 且有日志;标记处理不会把原文其它部分误删。
|
|
63
|
+
- **潜在错误原因**:大小写处理遗漏;duration 单位错用(秒/毫秒);ffprobe 路径优先级颠倒;JSON 标记解析失败未清理导致下游发送异常;文件不存在未提示。
|
|
64
|
+
|