@aster110/cc2wechat 3.1.0 → 3.2.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/CODE_REVIEW.md +182 -0
- package/EXPERIENCE.md +42 -1
- package/TEST_REVIEW.md +55 -0
- package/package.json +4 -2
- package/tests/store.test.ts +112 -0
- package/tests/utils.test.ts +116 -0
- package/tests/wechat-api.test.ts +223 -0
- package/vitest.config.ts +7 -0
package/CODE_REVIEW.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Code Review: v3 重构后代码审查
|
|
2
|
+
|
|
3
|
+
> 审查日期: 2026-03-22 | 审查范围: daemon.ts, handlers/, utils.ts, auth.ts, wechat-api.ts
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 总评
|
|
8
|
+
|
|
9
|
+
重构后模块边界清晰,代码量控制得很好。daemon.ts 精简为纯路由层(~180行),两个 handler 各司其职。主要问题集中在:少量代码重复、几处类型安全可改进、pipe.ts 的错误处理略粗糙。
|
|
10
|
+
|
|
11
|
+
**评级: B+** — 可生产使用,有几个值得迭代的改进点。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 一、模块职责(单一职责原则)
|
|
16
|
+
|
|
17
|
+
| 模块 | 职责 | 评价 |
|
|
18
|
+
|------|------|------|
|
|
19
|
+
| daemon.ts | 长轮询 + 消息路由(macOS→terminal, 其他→pipe) | 清晰。路由逻辑只有一个 if/else |
|
|
20
|
+
| handlers/terminal.ts | iTerm 窗口管理 + CC interactive 注入 | 清晰。iTerm AppleScript 全封装在此 |
|
|
21
|
+
| handlers/pipe.ts | claude -p 管道模式 + 自动回复 | 清晰。但包含了 markdown 清理和分片逻辑,可考虑抽到 utils |
|
|
22
|
+
| utils.ts | extractText + userIdToSessionUUID + sleep | 清晰。公共工具函数 |
|
|
23
|
+
| auth.ts | QR 登录(终端 + 网页两种模式) | 清晰。两种登录方式都在一个文件 |
|
|
24
|
+
| wechat-api.ts | 微信 API 封装 | 清晰。纯 HTTP 调用层,无业务逻辑 |
|
|
25
|
+
| store.ts | 凭证 + sync buf 持久化 | 清晰 |
|
|
26
|
+
| types.ts | 类型定义 | 清晰 |
|
|
27
|
+
|
|
28
|
+
**结论**: 模块职责单一,边界合理。
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 二、依赖方向
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
daemon.ts
|
|
36
|
+
├── auth.ts (登录)
|
|
37
|
+
├── store.ts (凭证/游标)
|
|
38
|
+
├── wechat-api.ts (API 调用)
|
|
39
|
+
├── utils.ts (公共工具)
|
|
40
|
+
├── types.ts (类型)
|
|
41
|
+
├── handlers/terminal.ts (macOS)
|
|
42
|
+
└── handlers/pipe.ts (Windows/Linux)
|
|
43
|
+
|
|
44
|
+
handlers/terminal.ts
|
|
45
|
+
├── types.ts
|
|
46
|
+
├── store.ts (type only)
|
|
47
|
+
├── wechat-api.ts ← 未使用但 import 了
|
|
48
|
+
└── utils.ts
|
|
49
|
+
|
|
50
|
+
handlers/pipe.ts
|
|
51
|
+
├── types.ts
|
|
52
|
+
├── store.ts (type only)
|
|
53
|
+
├── utils.ts
|
|
54
|
+
└── wechat-api.ts (动态 import sendMessage)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**无循环依赖。** 依赖方向合理:上层依赖下层,handler 之间无互相依赖。
|
|
58
|
+
|
|
59
|
+
### 问题
|
|
60
|
+
|
|
61
|
+
1. **terminal.ts 第 6 行**: import 了 `getConfig` 和 `sendTyping`,但函数体内未使用。这两个调用在 daemon.ts 的 `handleMessage` 里已经做了。**建议删除未使用的 import。**
|
|
62
|
+
|
|
63
|
+
2. **pipe.ts 第 48 行**: `await import('../wechat-api.js')` 用了动态 import,而同文件顶部没有静态 import wechat-api。这样做可能是为了避免循环依赖,但实际不存在循环问题。**建议改为顶部静态 import。**
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 三、错误处理
|
|
68
|
+
|
|
69
|
+
### daemon.ts — 良好
|
|
70
|
+
|
|
71
|
+
- pollLoop 有完善的 try/catch,连续失败计数 + 退避策略
|
|
72
|
+
- session 过期(errcode=-14)有专门处理
|
|
73
|
+
- typing 发送失败被正确吞掉(non-critical)
|
|
74
|
+
|
|
75
|
+
### handlers/terminal.ts — 一般
|
|
76
|
+
|
|
77
|
+
- `tabExists` 的 catch 返回 false,合理
|
|
78
|
+
- tab registry 加载的 catch 是空的(第 28 行),可以加个 console.warn
|
|
79
|
+
- **缺失**: `createTabAndStartCC` 中 `execSync` 的 AppleScript 可能失败(iTerm 未运行),没有 try/catch。**建议包一层 try/catch,失败时 fallback 到 pipe 模式或给出明确错误。**
|
|
80
|
+
- **缺失**: `injectMessage` 中 `execSync` 同样没有错误处理
|
|
81
|
+
|
|
82
|
+
### handlers/pipe.ts — 粗糙
|
|
83
|
+
|
|
84
|
+
- 第 31-45 行: 错误处理用 `err as { stdout?: string }` 类型断言,不够安全
|
|
85
|
+
- 嵌套的 try/catch(先试 `--resume`,失败再试 `--session-id`)逻辑可以简化
|
|
86
|
+
- 如果两次都失败,返回的错误信息可能丢失原始错误
|
|
87
|
+
|
|
88
|
+
### auth.ts — 良好
|
|
89
|
+
|
|
90
|
+
- QR 过期有自动刷新逻辑
|
|
91
|
+
- HTTP server 在 finally 块中关闭
|
|
92
|
+
- 超时有明确的错误提示
|
|
93
|
+
|
|
94
|
+
### wechat-api.ts — 良好
|
|
95
|
+
|
|
96
|
+
- apiFetch 统一了超时 + abort 处理
|
|
97
|
+
- clearTimeout 在 catch 和 正常路径都有
|
|
98
|
+
- AbortError 被正确识别并降级处理
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 四、类型安全
|
|
103
|
+
|
|
104
|
+
### any 使用情况
|
|
105
|
+
|
|
106
|
+
**无显式 `any`**。整体类型安全。
|
|
107
|
+
|
|
108
|
+
### 可改进的类型断言
|
|
109
|
+
|
|
110
|
+
| 位置 | 代码 | 问题 |
|
|
111
|
+
|------|------|------|
|
|
112
|
+
| pipe.ts:32 | `err as { stdout?: string; stderr?: string; message?: string }` | 应使用 node 的 `ExecException` 或自定义 guard |
|
|
113
|
+
| pipe.ts:40 | `err2 as { stdout?: string; message?: string }` | 同上 |
|
|
114
|
+
| terminal.ts:24 | `v as string` | entries 返回 unknown,可以加 typeof 检查 |
|
|
115
|
+
|
|
116
|
+
### types.ts 的 optional 泛滥
|
|
117
|
+
|
|
118
|
+
所有字段都是 `?` 可选的。这是对接外部 API 的常见做法,可以接受,但在关键路径上(如 `from_user_id`、`context_token`)可以考虑加 runtime validation。
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 五、代码重复
|
|
123
|
+
|
|
124
|
+
### 1. sleep 函数重复定义
|
|
125
|
+
|
|
126
|
+
- `daemon.ts:26-28`: 定义了 `sleep()`
|
|
127
|
+
- `utils.ts:28-30`: 也定义了 `sleep()`
|
|
128
|
+
|
|
129
|
+
daemon.ts 应该直接 import utils 的 sleep。
|
|
130
|
+
|
|
131
|
+
### 2. extractText + 路由上下文重复
|
|
132
|
+
|
|
133
|
+
daemon.ts 的 `handleMessage` 调用了 `extractText(msg)` 并提取 `userId`、`contextToken`。handlers/terminal.ts 和 handlers/pipe.ts 内部又各自调用了 `extractText(msg)` 和提取 `userId`。
|
|
134
|
+
|
|
135
|
+
**建议**: handleMessage 把解析好的数据通过参数传给 handler,避免重复解析。定义一个 `ParsedMessage` 接口:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
interface ParsedMessage {
|
|
139
|
+
text: string;
|
|
140
|
+
userId: string;
|
|
141
|
+
contextToken: string;
|
|
142
|
+
sessionId: string;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 3. context 写文件
|
|
147
|
+
|
|
148
|
+
daemon.ts:42 写 `/tmp/cc2wechat-context.json`,这是给 reply-cli 用的。但 terminal 模式下 reply-cli 可能不需要。可以只在 pipe 模式下写。
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 六、其他发现
|
|
153
|
+
|
|
154
|
+
### 安全
|
|
155
|
+
|
|
156
|
+
- `/tmp/cc2wechat-context.json` 包含 token,写在 /tmp 下任何用户可读。**建议**: 用 `fs.writeFileSync` 后 `chmodSync(0o600)`,或写到 `~/.claude/` 下。
|
|
157
|
+
- `/tmp/cc2wechat-tabs.json` 同理,虽然不含敏感信息。
|
|
158
|
+
|
|
159
|
+
### 性能
|
|
160
|
+
|
|
161
|
+
- terminal.ts 中 `tabExists` 每次调用都 `execSync` 一个 AppleScript,同步阻塞。对于消息处理来说可接受(消息频率不高),但如果频率上来会成瓶颈。
|
|
162
|
+
|
|
163
|
+
### 代码风格
|
|
164
|
+
|
|
165
|
+
- 注释清晰,分区用 `// ---` 分隔线,风格统一
|
|
166
|
+
- 常量命名一致(全大写 + 下划线)
|
|
167
|
+
- 文件长度合理:daemon 180行、terminal 104行、pipe 56行、utils 30行
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 七、改进建议(优先级排序)
|
|
172
|
+
|
|
173
|
+
| 优先级 | 建议 | 影响 |
|
|
174
|
+
|--------|------|------|
|
|
175
|
+
| P0 | daemon.ts 的 sleep 改为 import utils.sleep | 消除重复 |
|
|
176
|
+
| P0 | terminal.ts 删除未使用的 getConfig/sendTyping import | 代码整洁 |
|
|
177
|
+
| P1 | terminal.ts 的 execSync 加 try/catch | 防止 iTerm 未运行时崩溃 |
|
|
178
|
+
| P1 | /tmp/cc2wechat-context.json 加 chmod 600 | 安全 |
|
|
179
|
+
| P1 | pipe.ts 动态 import 改静态 import | 代码一致性 |
|
|
180
|
+
| P2 | 定义 ParsedMessage 接口,避免 handler 重复解析 | 减少重复 |
|
|
181
|
+
| P2 | pipe.ts 错误处理简化,使用 node ExecException 类型 | 类型安全 |
|
|
182
|
+
| P3 | types.ts 关键字段加 runtime validation | 健壮性 |
|
package/EXPERIENCE.md
CHANGED
|
@@ -139,10 +139,51 @@ sendMessage 的 `image_item.url` 字段虽然存在,但直接传远程 URL 微
|
|
|
139
139
|
- image_item.media.aes_key: base64(hex string)
|
|
140
140
|
- CDN Base URL: `https://novac2c.cdn.weixin.qq.com/c2c`
|
|
141
141
|
|
|
142
|
-
##
|
|
142
|
+
## 六、v3 Interactive Terminal 模式踩坑
|
|
143
|
+
|
|
144
|
+
> 2026-03-22 v3 重构
|
|
145
|
+
|
|
146
|
+
### 坑 9:iTerm tab name 被 CC 覆盖
|
|
147
|
+
|
|
148
|
+
最初用 tab name(`wechat-xxx`)标识用户窗口。问题:CC 启动后会把 tab title 改成自己的(显示 session info),导致 `tabExists()` 按 name 找不到已有窗口,重复创建。
|
|
149
|
+
|
|
150
|
+
**解决方案**: 改用 iTerm window id。创建窗口时 `return id of w` 拿到数字 id,后续用 `first window whose id is <id>` 定位。window id 是 iTerm 内部分配的,CC 改不了。
|
|
151
|
+
|
|
152
|
+
### 坑 10:多实例竞争 — tab registry 持久化
|
|
153
|
+
|
|
154
|
+
daemon 重启后内存中的 `userTabs` Map 清空,无法找到之前创建的 iTerm 窗口。导致每次重启都创建新窗口,老窗口变成孤儿。
|
|
155
|
+
|
|
156
|
+
**解决方案**: 把 `tabName → windowId` 的映射持久化到 `/tmp/cc2wechat-tabs.json`。启动时加载,每次创建/更新窗口时写入。`tabExists()` 先查 registry 拿到 window id,再用 AppleScript 验证窗口是否还在。
|
|
157
|
+
|
|
158
|
+
### 坑 11:AppleScript 中 iTerm vs iTerm2 名称差异
|
|
159
|
+
|
|
160
|
+
AppleScript 里 iTerm 的 application name 是 `"iTerm2"` 而不是 `"iTerm"`。写成 `tell application "iTerm"` 会弹出 "找不到应用" 的对话框,阻塞 execSync。
|
|
161
|
+
|
|
162
|
+
**解决方案**: 统一用 `tell application "iTerm2"`。注意 Finder 里显示的名字是 "iTerm",但 AppleScript identifier 是 "iTerm2"。
|
|
163
|
+
|
|
164
|
+
### 坑 12:claude -p 不支持 Agent Teams
|
|
165
|
+
|
|
166
|
+
v2 pipe 模式用 `claude -p <prompt>` 调用 CC。这种模式下 CC 以单次问答模式运行,**不支持 Agent Teams**(spawn worker、TeamCreate 等)。这是 v3 改用 interactive terminal 模式的核心原因。
|
|
167
|
+
|
|
168
|
+
interactive 模式(`claude --resume`)下 CC 拥有完整能力:Agent Teams、MCP tools、multi-turn 等。
|
|
169
|
+
|
|
170
|
+
### 坑 13:system prompt 的 reply-cli 路径问题
|
|
171
|
+
|
|
172
|
+
pipe 模式的 system prompt 里写了 `node ${replyCli} --text "reply"`,其中 `replyCli` 用 `import.meta.url` 算出来的绝对路径。问题:
|
|
173
|
+
|
|
174
|
+
1. 路径指向 `dist/` 编译产物目录,但 `reply-cli.js` 可能不在预期位置
|
|
175
|
+
2. 如果用 npx 安装,路径是临时目录,下次运行就变了
|
|
176
|
+
|
|
177
|
+
**解决方案**: `path.join(__dirname, '..', 'reply-cli.js')` 基于当前文件相对定位。terminal 模式下由 CC 的 system prompt(在 skill 文件里)指定 reply-cli 路径,不硬编码在代码里。
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 七、待解决
|
|
143
182
|
|
|
144
183
|
1. **CDN 媒体上传/下载**:图片/文件需要 AES-128-ECB 加密,openclaw 插件有完整实现可参考
|
|
145
184
|
2. **Channel notification 实测**:需要用 `--dangerously-load-development-channels` 启动 CC 测试
|
|
146
185
|
3. **多账号支持**:当前只支持一个账号
|
|
147
186
|
4. **重连机制**:token 失效后是否需要重新扫码?
|
|
148
187
|
5. **npm 包发布**:package name、README、license
|
|
188
|
+
6. **terminal.ts execSync 无 try/catch**:iTerm 未运行时 AppleScript 会抛错,应 fallback 到 pipe 模式
|
|
189
|
+
7. **/tmp 安全**:context.json 含 token,应 chmod 600 或移到 ~/.claude/ 下
|
package/TEST_REVIEW.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
## 测试审查报告
|
|
2
|
+
|
|
3
|
+
### utils.test.ts
|
|
4
|
+
- 覆盖率:2/3 个 export 函数被测(`extractText`, `userIdToSessionUUID`)
|
|
5
|
+
- 问题:
|
|
6
|
+
1. **`sleep` 未测试** — 虽然实现简单(Promise + setTimeout),但作为 export 函数应有基本测试
|
|
7
|
+
2. **extractText 边界遗漏** — VOICE 没有 `voice_item.text`、FILE 没有 `file_item.file_name` 时,item 被静默跳过(不产出任何文本),这个行为未被测试覆盖。如果只有一个这样的 item,结果会是 `[Empty message]`,可能不符合预期
|
|
8
|
+
3. **extractText 未知类型遗漏** — `item.type` 不在已知枚举中时(如 type=99),item 被静默跳过,未测试
|
|
9
|
+
4. **userIdToSessionUUID UUID variant 位未校验** — 测试 regex 允许 variant nibble 为任意 hex,实际 UUID v4 要求 `[89ab]`。源码也没做 variant 处理,所以测试和源码一致,但生成的不是严格合规的 UUID v4
|
|
10
|
+
- 建议:
|
|
11
|
+
- 补 `sleep` 基本测试(resolve 且耗时 >= N ms)
|
|
12
|
+
- 补 VOICE 无 text、FILE 无 file_name 的边界测试
|
|
13
|
+
- 补未知 type 的测试
|
|
14
|
+
|
|
15
|
+
### store.test.ts
|
|
16
|
+
- 覆盖率:5/6 个 export 函数被测(`saveAccount`, `getActiveAccount`, `loadAccounts`, `loadSyncBuf`, `saveSyncBuf`)
|
|
17
|
+
- 问题:
|
|
18
|
+
1. **`removeAccount` 未测试** — 删除账户功能完全没覆盖
|
|
19
|
+
2. **accounts.json 文件权限未验证** — `saveAccount` 会 `chmod 0o600`,测试未验证文件权限
|
|
20
|
+
3. **并发写入未测试** — 两个 `saveAccount` 同时调用可能导致数据丢失(read-modify-write 竞态),虽然 Node 单线程不太会触发,但多进程场景下有风险
|
|
21
|
+
4. **corrupted JSON 测试缺失** — `loadAccounts` 对 JSON.parse 失败会 catch 返回 `[]`,但没测试 accounts.json 内容损坏的场景
|
|
22
|
+
- 建议:
|
|
23
|
+
- 补 `removeAccount` 测试(删除存在的、删除不存在的)
|
|
24
|
+
- 补 corrupted JSON 文件测试(验证 graceful fallback)
|
|
25
|
+
|
|
26
|
+
### wechat-api.test.ts
|
|
27
|
+
- 覆盖率:2/9 个 export 函数被测(`encryptAesEcb`, `aesEcbPaddedSize`)
|
|
28
|
+
- 问题:
|
|
29
|
+
1. **7 个 async API 函数完全未测** — `getQRCode`, `pollQRStatus`, `getUpdates`, `sendMessage`, `sendTyping`, `getConfig`, `uploadAndSendMedia` 均无测试
|
|
30
|
+
2. 这些函数包含重要逻辑(超时处理、AbortError fallback、header 构建、错误处理),不能因为"需要 mock fetch"就跳过
|
|
31
|
+
3. **encryptAesEcb 空输入未测** — 空 Buffer 加密是合法操作,未覆盖
|
|
32
|
+
- 建议:
|
|
33
|
+
- 用 `vi.stubGlobal('fetch', ...)` 或 `msw` mock fetch,补充以下测试:
|
|
34
|
+
- `getUpdates`:正常返回、AbortError 返回默认值、HTTP 错误抛异常
|
|
35
|
+
- `sendMessage`:正常发送、失败抛异常
|
|
36
|
+
- `getQRCode`:正常返回、HTTP 错误
|
|
37
|
+
- `pollQRStatus`:各 status 返回值、超时返回 `{ status: 'wait' }`
|
|
38
|
+
- `sendTyping` / `getConfig`:基本成功/失败
|
|
39
|
+
- `uploadAndSendMedia`:至少测 getUploadUrl 无 upload_param 时抛错
|
|
40
|
+
- 补 `encryptAesEcb(Buffer.alloc(0), key)` 边界测试
|
|
41
|
+
|
|
42
|
+
### 总体评价
|
|
43
|
+
**需修改**
|
|
44
|
+
|
|
45
|
+
纯工具函数(crypto、文件存储)测试质量不错,断言精确、覆盖了关键 case。但项目的核心价值在 WeChat API 交互层,7 个 async 函数零测试是最大短板。store 缺 `removeAccount`、utils 缺 `sleep` 属于小问题。
|
|
46
|
+
|
|
47
|
+
### 需要 test-fixer 修复的问题
|
|
48
|
+
1. **[P0]** wechat-api.test.ts:mock fetch 后补 `getUpdates`、`sendMessage`、`getQRCode`、`pollQRStatus` 测试(至少覆盖正常路径 + 错误路径 + 超时路径)
|
|
49
|
+
2. **[P0]** wechat-api.test.ts:补 `sendTyping`、`getConfig` 基本测试
|
|
50
|
+
3. **[P1]** store.test.ts:补 `removeAccount` 测试
|
|
51
|
+
4. **[P1]** utils.test.ts:补 `sleep` 测试
|
|
52
|
+
5. **[P2]** utils.test.ts:补 extractText 的 VOICE 无 text、FILE 无 file_name、未知 type 边界测试
|
|
53
|
+
6. **[P2]** store.test.ts:补 corrupted JSON fallback 测试
|
|
54
|
+
7. **[P2]** wechat-api.test.ts:补 `encryptAesEcb` 空 Buffer 边界测试
|
|
55
|
+
8. **[P3]** wechat-api.test.ts:补 `uploadAndSendMedia` 测试(复杂度高,可后续补)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aster110/cc2wechat",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "WeChat channel for Claude Code — chat with Claude Code from WeChat via iLink Bot API",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"dev": "tsx src/server.ts",
|
|
14
|
+
"test": "vitest run",
|
|
14
15
|
"prepublishOnly": "npm run build"
|
|
15
16
|
},
|
|
16
17
|
"engines": {
|
|
@@ -23,7 +24,8 @@
|
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@types/node": "^22.0.0",
|
|
25
26
|
"tsx": "^4.19.0",
|
|
26
|
-
"typescript": "^5.7.0"
|
|
27
|
+
"typescript": "^5.7.0",
|
|
28
|
+
"vitest": "^3.0.0"
|
|
27
29
|
},
|
|
28
30
|
"license": "MIT"
|
|
29
31
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
// We need to mock the CHANNEL_DIR to use a temp directory
|
|
7
|
+
// Since CHANNEL_DIR is a module-level const, we mock os.homedir before importing
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc2wechat-test-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Since store.ts computes CHANNEL_DIR at module load time using os.homedir(),
|
|
19
|
+
// we need to re-import with a mocked homedir each time.
|
|
20
|
+
async function importStore(homeDir: string) {
|
|
21
|
+
vi.doMock('node:os', async () => {
|
|
22
|
+
const actual = await vi.importActual<typeof import('node:os')>('node:os');
|
|
23
|
+
return { ...actual, default: { ...actual, homedir: () => homeDir }, homedir: () => homeDir };
|
|
24
|
+
});
|
|
25
|
+
// Force fresh import
|
|
26
|
+
const mod = await import('../src/store.js');
|
|
27
|
+
return mod;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('store', () => {
|
|
31
|
+
let store: Awaited<ReturnType<typeof importStore>>;
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
vi.resetModules();
|
|
35
|
+
store = await importStore(tmpDir);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('saveAccount / getActiveAccount / loadAccounts', () => {
|
|
43
|
+
it('returns null when no accounts saved', () => {
|
|
44
|
+
expect(store.getActiveAccount()).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('saves and retrieves an account', () => {
|
|
48
|
+
const account = {
|
|
49
|
+
accountId: 'acc1',
|
|
50
|
+
token: 'tok_abc',
|
|
51
|
+
baseUrl: 'https://example.com',
|
|
52
|
+
savedAt: '2026-01-01T00:00:00Z',
|
|
53
|
+
};
|
|
54
|
+
store.saveAccount(account);
|
|
55
|
+
const active = store.getActiveAccount();
|
|
56
|
+
expect(active).not.toBeNull();
|
|
57
|
+
expect(active!.accountId).toBe('acc1');
|
|
58
|
+
expect(active!.token).toBe('tok_abc');
|
|
59
|
+
expect(active!.baseUrl).toBe('https://example.com');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns the last saved account as active', () => {
|
|
63
|
+
store.saveAccount({ accountId: 'a1', token: 't1', savedAt: '2026-01-01' });
|
|
64
|
+
store.saveAccount({ accountId: 'a2', token: 't2', savedAt: '2026-01-02' });
|
|
65
|
+
const active = store.getActiveAccount();
|
|
66
|
+
expect(active!.accountId).toBe('a2');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('deduplicates by accountId on save', () => {
|
|
70
|
+
store.saveAccount({ accountId: 'a1', token: 'old', savedAt: '2026-01-01' });
|
|
71
|
+
store.saveAccount({ accountId: 'a1', token: 'new', savedAt: '2026-01-02' });
|
|
72
|
+
const accounts = store.loadAccounts();
|
|
73
|
+
expect(accounts).toHaveLength(1);
|
|
74
|
+
expect(accounts[0]!.token).toBe('new');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('removeAccount', () => {
|
|
79
|
+
it('removes an existing account', () => {
|
|
80
|
+
store.saveAccount({ accountId: 'a1', token: 't1', savedAt: '2026-01-01' });
|
|
81
|
+
store.saveAccount({ accountId: 'a2', token: 't2', savedAt: '2026-01-02' });
|
|
82
|
+
store.removeAccount('a1');
|
|
83
|
+
const accounts = store.loadAccounts();
|
|
84
|
+
expect(accounts).toHaveLength(1);
|
|
85
|
+
expect(accounts[0]!.accountId).toBe('a2');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does nothing when removing a non-existent account', () => {
|
|
89
|
+
store.saveAccount({ accountId: 'a1', token: 't1', savedAt: '2026-01-01' });
|
|
90
|
+
store.removeAccount('nonexistent');
|
|
91
|
+
const accounts = store.loadAccounts();
|
|
92
|
+
expect(accounts).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('loadSyncBuf / saveSyncBuf', () => {
|
|
97
|
+
it('returns empty string when no buf saved', () => {
|
|
98
|
+
expect(store.loadSyncBuf('nonexistent')).toBe('');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('saves and loads sync buf consistently', () => {
|
|
102
|
+
store.saveSyncBuf('acc1', 'some_cursor_data_12345');
|
|
103
|
+
expect(store.loadSyncBuf('acc1')).toBe('some_cursor_data_12345');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('overwrites previous buf', () => {
|
|
107
|
+
store.saveSyncBuf('acc1', 'first');
|
|
108
|
+
store.saveSyncBuf('acc1', 'second');
|
|
109
|
+
expect(store.loadSyncBuf('acc1')).toBe('second');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { extractText, userIdToSessionUUID, sleep } from '../src/utils.js';
|
|
3
|
+
import { MessageItemType } from '../src/types.js';
|
|
4
|
+
import type { WeixinMessage } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
describe('extractText', () => {
|
|
7
|
+
it('extracts text from a text message', () => {
|
|
8
|
+
const msg: WeixinMessage = {
|
|
9
|
+
item_list: [{ type: MessageItemType.TEXT, text_item: { text: 'hello world' } }],
|
|
10
|
+
};
|
|
11
|
+
expect(extractText(msg)).toBe('hello world');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns [Image] for image messages', () => {
|
|
15
|
+
const msg: WeixinMessage = {
|
|
16
|
+
item_list: [{ type: MessageItemType.IMAGE }],
|
|
17
|
+
};
|
|
18
|
+
expect(extractText(msg)).toBe('[Image]');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('extracts voice text', () => {
|
|
22
|
+
const msg: WeixinMessage = {
|
|
23
|
+
item_list: [{ type: MessageItemType.VOICE, voice_item: { text: 'voice content' } }],
|
|
24
|
+
};
|
|
25
|
+
expect(extractText(msg)).toBe('[Voice] voice content');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns [Video] for video messages', () => {
|
|
29
|
+
const msg: WeixinMessage = {
|
|
30
|
+
item_list: [{ type: MessageItemType.VIDEO }],
|
|
31
|
+
};
|
|
32
|
+
expect(extractText(msg)).toBe('[Video]');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('extracts file name', () => {
|
|
36
|
+
const msg: WeixinMessage = {
|
|
37
|
+
item_list: [{ type: MessageItemType.FILE, file_item: { file_name: 'doc.pdf' } }],
|
|
38
|
+
};
|
|
39
|
+
expect(extractText(msg)).toBe('[File: doc.pdf]');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns [Empty message] for empty item_list', () => {
|
|
43
|
+
const msg: WeixinMessage = { item_list: [] };
|
|
44
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns [Empty message] when item_list is undefined', () => {
|
|
48
|
+
const msg: WeixinMessage = {};
|
|
49
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('joins multiple items with newline', () => {
|
|
53
|
+
const msg: WeixinMessage = {
|
|
54
|
+
item_list: [
|
|
55
|
+
{ type: MessageItemType.TEXT, text_item: { text: 'line1' } },
|
|
56
|
+
{ type: MessageItemType.IMAGE },
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
expect(extractText(msg)).toBe('line1\n[Image]');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns [Empty message] for VOICE without text', () => {
|
|
63
|
+
const msg: WeixinMessage = {
|
|
64
|
+
item_list: [{ type: MessageItemType.VOICE, voice_item: {} }],
|
|
65
|
+
};
|
|
66
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns [Empty message] for FILE without file_name', () => {
|
|
70
|
+
const msg: WeixinMessage = {
|
|
71
|
+
item_list: [{ type: MessageItemType.FILE, file_item: {} }],
|
|
72
|
+
};
|
|
73
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('skips items with unknown type', () => {
|
|
77
|
+
const msg: WeixinMessage = {
|
|
78
|
+
item_list: [{ type: 99 }],
|
|
79
|
+
};
|
|
80
|
+
expect(extractText(msg)).toBe('[Empty message]');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('userIdToSessionUUID', () => {
|
|
85
|
+
it('is deterministic (same input same output)', () => {
|
|
86
|
+
const a = userIdToSessionUUID('user123');
|
|
87
|
+
const b = userIdToSessionUUID('user123');
|
|
88
|
+
expect(a).toBe(b);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns valid UUID v4 format', () => {
|
|
92
|
+
const uuid = userIdToSessionUUID('testuser');
|
|
93
|
+
// UUID format: 8-4-4-4-12
|
|
94
|
+
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('produces different outputs for different inputs', () => {
|
|
98
|
+
const a = userIdToSessionUUID('user_a');
|
|
99
|
+
const b = userIdToSessionUUID('user_b');
|
|
100
|
+
expect(a).not.toBe(b);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('sleep', () => {
|
|
105
|
+
it('resolves after the specified duration', async () => {
|
|
106
|
+
const start = Date.now();
|
|
107
|
+
await sleep(50);
|
|
108
|
+
const elapsed = Date.now() - start;
|
|
109
|
+
expect(elapsed).toBeGreaterThanOrEqual(40); // allow small timing variance
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('resolves to undefined', async () => {
|
|
113
|
+
const result = await sleep(1);
|
|
114
|
+
expect(result).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
encryptAesEcb,
|
|
5
|
+
aesEcbPaddedSize,
|
|
6
|
+
getUpdates,
|
|
7
|
+
sendMessage,
|
|
8
|
+
getQRCode,
|
|
9
|
+
pollQRStatus,
|
|
10
|
+
sendTyping,
|
|
11
|
+
getConfig,
|
|
12
|
+
} from '../src/wechat-api.js';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Mock fetch helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
function mockFetchJson(data: unknown, status = 200) {
|
|
19
|
+
return vi.fn().mockResolvedValue({
|
|
20
|
+
ok: status >= 200 && status < 300,
|
|
21
|
+
status,
|
|
22
|
+
statusText: status === 200 ? 'OK' : 'Error',
|
|
23
|
+
text: () => Promise.resolve(JSON.stringify(data)),
|
|
24
|
+
json: () => Promise.resolve(data),
|
|
25
|
+
headers: new Headers(),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockFetchText(text: string, status = 200) {
|
|
30
|
+
return vi.fn().mockResolvedValue({
|
|
31
|
+
ok: status >= 200 && status < 300,
|
|
32
|
+
status,
|
|
33
|
+
statusText: status === 200 ? 'OK' : 'Error',
|
|
34
|
+
text: () => Promise.resolve(text),
|
|
35
|
+
json: () => Promise.resolve(JSON.parse(text)),
|
|
36
|
+
headers: new Headers(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mockFetchAbort() {
|
|
41
|
+
return vi.fn().mockRejectedValue(Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// API function tests
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
describe('getUpdates', () => {
|
|
49
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
50
|
+
|
|
51
|
+
it('returns parsed messages on success', async () => {
|
|
52
|
+
const resp = { ret: 0, msgs: [{ message_id: 1 }], get_updates_buf: 'buf2' };
|
|
53
|
+
vi.stubGlobal('fetch', mockFetchText(JSON.stringify(resp)));
|
|
54
|
+
|
|
55
|
+
const result = await getUpdates('tok', 'buf1');
|
|
56
|
+
expect(result.ret).toBe(0);
|
|
57
|
+
expect(result.msgs).toHaveLength(1);
|
|
58
|
+
expect(result.get_updates_buf).toBe('buf2');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns default value on AbortError (timeout)', async () => {
|
|
62
|
+
vi.stubGlobal('fetch', mockFetchAbort());
|
|
63
|
+
|
|
64
|
+
const result = await getUpdates('tok', 'buf1');
|
|
65
|
+
expect(result.ret).toBe(0);
|
|
66
|
+
expect(result.msgs).toEqual([]);
|
|
67
|
+
expect(result.get_updates_buf).toBe('buf1');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('sendMessage', () => {
|
|
72
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
73
|
+
|
|
74
|
+
it('sends message successfully (no throw)', async () => {
|
|
75
|
+
vi.stubGlobal('fetch', mockFetchText('{"ret":0}'));
|
|
76
|
+
await expect(sendMessage('tok', 'user1', 'hello', 'ctx1')).resolves.toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('getQRCode', () => {
|
|
81
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
82
|
+
|
|
83
|
+
it('returns qrcode and qrcode_img_content', async () => {
|
|
84
|
+
const data = { qrcode: 'qr123', qrcode_img_content: 'base64img' };
|
|
85
|
+
vi.stubGlobal('fetch', mockFetchJson(data));
|
|
86
|
+
|
|
87
|
+
const result = await getQRCode();
|
|
88
|
+
expect(result.qrcode).toBe('qr123');
|
|
89
|
+
expect(result.qrcode_img_content).toBe('base64img');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('pollQRStatus', () => {
|
|
94
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
95
|
+
|
|
96
|
+
it('returns wait status', async () => {
|
|
97
|
+
vi.stubGlobal('fetch', mockFetchJson({ status: 'wait' }));
|
|
98
|
+
const result = await pollQRStatus('qr123');
|
|
99
|
+
expect(result.status).toBe('wait');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns confirmed status with token', async () => {
|
|
103
|
+
const data = { status: 'confirmed', bot_token: 'tok_abc', ilink_bot_id: 'bot1' };
|
|
104
|
+
vi.stubGlobal('fetch', mockFetchJson(data));
|
|
105
|
+
const result = await pollQRStatus('qr123');
|
|
106
|
+
expect(result.status).toBe('confirmed');
|
|
107
|
+
expect(result.bot_token).toBe('tok_abc');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns { status: "wait" } on AbortError (timeout)', async () => {
|
|
111
|
+
vi.stubGlobal('fetch', mockFetchAbort());
|
|
112
|
+
const result = await pollQRStatus('qr123');
|
|
113
|
+
expect(result.status).toBe('wait');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('sendTyping', () => {
|
|
118
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
119
|
+
|
|
120
|
+
it('sends typing successfully (no throw)', async () => {
|
|
121
|
+
vi.stubGlobal('fetch', mockFetchText('{"ret":0}'));
|
|
122
|
+
await expect(sendTyping('tok', 'user1', 'ticket1')).resolves.toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('getConfig', () => {
|
|
127
|
+
afterEach(() => { vi.unstubAllGlobals(); });
|
|
128
|
+
|
|
129
|
+
it('returns typing_ticket', async () => {
|
|
130
|
+
const data = { ret: 0, typing_ticket: 'ticket_xyz' };
|
|
131
|
+
vi.stubGlobal('fetch', mockFetchText(JSON.stringify(data)));
|
|
132
|
+
|
|
133
|
+
const result = await getConfig('tok', 'user1');
|
|
134
|
+
expect(result.typing_ticket).toBe('ticket_xyz');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Crypto tests
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
describe('encryptAesEcb', () => {
|
|
143
|
+
it('encrypt then decrypt produces original plaintext', () => {
|
|
144
|
+
const key = crypto.randomBytes(16);
|
|
145
|
+
const plaintext = Buffer.from('Hello, WeChat!');
|
|
146
|
+
|
|
147
|
+
const ciphertext = encryptAesEcb(plaintext, key);
|
|
148
|
+
|
|
149
|
+
// Decrypt with node:crypto to verify
|
|
150
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
151
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
152
|
+
|
|
153
|
+
expect(decrypted.toString()).toBe('Hello, WeChat!');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('produces ciphertext different from plaintext', () => {
|
|
157
|
+
const key = crypto.randomBytes(16);
|
|
158
|
+
const plaintext = Buffer.from('test data here');
|
|
159
|
+
const ciphertext = encryptAesEcb(plaintext, key);
|
|
160
|
+
expect(ciphertext.equals(plaintext)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('different keys produce different ciphertext', () => {
|
|
164
|
+
const key1 = crypto.randomBytes(16);
|
|
165
|
+
const key2 = crypto.randomBytes(16);
|
|
166
|
+
const plaintext = Buffer.from('same input');
|
|
167
|
+
|
|
168
|
+
const c1 = encryptAesEcb(plaintext, key1);
|
|
169
|
+
const c2 = encryptAesEcb(plaintext, key2);
|
|
170
|
+
expect(c1.equals(c2)).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('ciphertext length matches aesEcbPaddedSize', () => {
|
|
174
|
+
const key = crypto.randomBytes(16);
|
|
175
|
+
const plaintext = Buffer.from('variable length content');
|
|
176
|
+
const ciphertext = encryptAesEcb(plaintext, key);
|
|
177
|
+
expect(ciphertext.length).toBe(aesEcbPaddedSize(plaintext.length));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('encrypts empty buffer without error', () => {
|
|
181
|
+
const key = crypto.randomBytes(16);
|
|
182
|
+
const ciphertext = encryptAesEcb(Buffer.alloc(0), key);
|
|
183
|
+
expect(ciphertext.length).toBe(16); // one full padding block
|
|
184
|
+
|
|
185
|
+
// Decrypt to verify
|
|
186
|
+
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null);
|
|
187
|
+
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
188
|
+
expect(decrypted.length).toBe(0);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('aesEcbPaddedSize', () => {
|
|
193
|
+
it('returns 16 for empty input (0 bytes)', () => {
|
|
194
|
+
// 0 bytes plaintext → 16 bytes padding (full block of padding)
|
|
195
|
+
expect(aesEcbPaddedSize(0)).toBe(16);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns 16 for 1 byte', () => {
|
|
199
|
+
expect(aesEcbPaddedSize(1)).toBe(16);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns 16 for 15 bytes', () => {
|
|
203
|
+
// 15 bytes + 1 padding = 16
|
|
204
|
+
expect(aesEcbPaddedSize(15)).toBe(16);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('returns 32 for 16 bytes (needs full padding block)', () => {
|
|
208
|
+
// 16 bytes plaintext → needs extra block for PKCS7 padding
|
|
209
|
+
expect(aesEcbPaddedSize(16)).toBe(32);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('returns 32 for 17 bytes', () => {
|
|
213
|
+
expect(aesEcbPaddedSize(17)).toBe(32);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('returns 32 for 31 bytes', () => {
|
|
217
|
+
expect(aesEcbPaddedSize(31)).toBe(32);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('returns 48 for 32 bytes', () => {
|
|
221
|
+
expect(aesEcbPaddedSize(32)).toBe(48);
|
|
222
|
+
});
|
|
223
|
+
});
|