返回 DeepSeek-Reasonix
bot_test.go
根目录 / internal / cli / bot_test.go
1 package cli
2
3 import (
4 "io"
5 "log/slog"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "reasonix/internal/bot"
12 "reasonix/internal/botruntime"
13 "reasonix/internal/config"
14 )
15
16 func TestRememberBotRemoteStoresIncomingChatID(t *testing.T) {
17 isolateBotUserConfig(t)
18 cfg := config.Default()
19 cfg.Bot.Connections = []config.BotConnectionConfig{
20 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
21 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
22 }
23 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
24 t.Fatalf("save config: %v", err)
25 }
26
27 msg := bot.InboundMessage{
28 Platform: bot.PlatformWeixin,
29 ChatType: bot.ChatDM,
30 ChatID: "wx-chat-1",
31 UserID: "wx-user-1",
32 }
33 if err := botruntime.RememberInbound(msg); err != nil {
34 t.Fatalf("rememberBotInbound: %v", err)
35 }
36 if err := botruntime.RememberInbound(msg); err != nil {
37 t.Fatalf("rememberBotRemote duplicate: %v", err)
38 }
39
40 got := config.LoadForEdit(config.UserConfigPath())
41 if len(got.Bot.Connections) != 2 {
42 t.Fatalf("connections = %d, want 2", len(got.Bot.Connections))
43 }
44 var wx config.BotConnectionConfig
45 var fs config.BotConnectionConfig
46 for _, conn := range got.Bot.Connections {
47 switch conn.ID {
48 case "weixin-weixin":
49 wx = conn
50 case "feishu-feishu":
51 fs = conn
52 }
53 }
54 if len(fs.SessionMappings) != 0 {
55 t.Fatalf("feishu mappings = %+v, want none", fs.SessionMappings)
56 }
57 if len(wx.SessionMappings) != 1 {
58 t.Fatalf("weixin mappings = %+v, want one", wx.SessionMappings)
59 }
60 if m := wx.SessionMappings[0]; m.RemoteID != "wx-chat-1" || m.Scope != "global" || m.WorkspaceRoot != "" || m.UpdatedAt == "" {
61 t.Fatalf("weixin mapping = %+v, want global wx-chat-1 with timestamp", m)
62 }
63 if got := got.Bot.Allowlist.WeixinUsers; len(got) != 1 || got[0] != "wx-user-1" {
64 t.Fatalf("weixin users = %+v, want wx-user-1", got)
65 }
66 }
67
68 func TestRememberBotRemoteKeepsProjectScopedConnection(t *testing.T) {
69 isolateBotUserConfig(t)
70 workspace := filepath.Join(t.TempDir(), "project")
71 cfg := config.Default()
72 cfg.Bot.Connections = []config.BotConnectionConfig{{
73 ID: "feishu-project",
74 Provider: "feishu",
75 Domain: "feishu",
76 Label: "飞书",
77 Enabled: true,
78 Status: "connected",
79 WorkspaceRoot: workspace,
80 }}
81 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
82 t.Fatalf("save config: %v", err)
83 }
84
85 if err := botruntime.RememberInbound(bot.InboundMessage{
86 Platform: bot.PlatformFeishu,
87 ChatType: bot.ChatDM,
88 ChatID: "oc-chat-1",
89 UserID: "ou-user-1",
90 }); err != nil {
91 t.Fatalf("rememberBotInbound: %v", err)
92 }
93
94 got := config.LoadForEdit(config.UserConfigPath())
95 if len(got.Bot.Connections) != 1 || len(got.Bot.Connections[0].SessionMappings) != 1 {
96 t.Fatalf("connections = %+v, want one project mapping", got.Bot.Connections)
97 }
98 if m := got.Bot.Connections[0].SessionMappings[0]; m.RemoteID != "oc-chat-1" || m.Scope != "project" || m.WorkspaceRoot != workspace {
99 t.Fatalf("mapping = %+v, want project scoped remote", m)
100 }
101 if got := got.Bot.Allowlist.FeishuUsers; len(got) != 1 || got[0] != "ou-user-1" {
102 t.Fatalf("feishu users = %+v, want ou-user-1", got)
103 }
104 }
105
106 func TestRememberBotInboundStoresGroupAllowlist(t *testing.T) {
107 isolateBotUserConfig(t)
108 cfg := config.Default()
109 cfg.Bot.Connections = []config.BotConnectionConfig{
110 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
111 }
112 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
113 t.Fatalf("save config: %v", err)
114 }
115
116 msg := bot.InboundMessage{
117 Platform: bot.PlatformFeishu,
118 ChatType: bot.ChatGroup,
119 ChatID: "oc-group-1",
120 UserID: "ou-user-1",
121 }
122 if err := botruntime.RememberInbound(msg); err != nil {
123 t.Fatalf("rememberBotInbound: %v", err)
124 }
125 if err := botruntime.RememberInbound(msg); err != nil {
126 t.Fatalf("rememberBotInbound duplicate: %v", err)
127 }
128
129 got := config.LoadForEdit(config.UserConfigPath())
130 if users := got.Bot.Allowlist.FeishuUsers; len(users) != 1 || users[0] != "ou-user-1" {
131 t.Fatalf("feishu users = %+v, want one ou-user-1", users)
132 }
133 if groups := got.Bot.Allowlist.FeishuGroups; len(groups) != 1 || groups[0] != "oc-group-1" {
134 t.Fatalf("feishu groups = %+v, want one oc-group-1", groups)
135 }
136 }
137
138 func TestBotDoctorReportsSessionMappingCounts(t *testing.T) {
139 isolateBotUserConfig(t)
140 cfg := config.Default()
141 cfg.Bot.Connections = []config.BotConnectionConfig{
142 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
143 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
144 }
145 cfg.Bot.Connections[0].SessionMappings = []config.BotConnectionSessionMapping{{RemoteID: "oc-chat-1", Scope: "global"}}
146 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
147 t.Fatalf("save config: %v", err)
148 }
149
150 out := captureStdout(t, func() {
151 if rc := botDoctor([]string{"--json"}); rc != 0 {
152 t.Fatalf("botDoctor rc = %d, want 0", rc)
153 }
154 })
155 for _, want := range []string{
156 `"name":"bot.connections","status":"ok","detail":"enabled=2 total=2"`,
157 `"name":"bot.connection.feishu-feishu.session_mappings","status":"ok","detail":"provider=feishu mappings=1"`,
158 `"name":"bot.connection.weixin-weixin.session_mappings","status":"missing","detail":"provider=weixin mappings=0"`,
159 } {
160 if !strings.Contains(out, want) {
161 t.Fatalf("bot doctor output missing %s:\n%s", want, out)
162 }
163 }
164 }
165
166 func TestBotDoctorDeepReportsPairingAndRoles(t *testing.T) {
167 isolateBotUserConfig(t)
168 cfg := config.Default()
169 cfg.Bot.Enabled = true
170 cfg.Bot.Pairing.Enabled = true
171 cfg.Bot.Allowlist.Enabled = true
172 cfg.Bot.Allowlist.FeishuUsers = []string{"ou-user"}
173 cfg.Bot.Allowlist.FeishuApprovers = []string{"ou-approver"}
174 cfg.Bot.Allowlist.FeishuAdmins = []string{"ou-admin"}
175 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
176 t.Fatalf("save config: %v", err)
177 }
178
179 if _, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
180 Platform: bot.PlatformFeishu,
181 ConnectionID: "feishu-feishu",
182 ChatType: bot.ChatDM,
183 ChatID: "chat",
184 UserID: "pending-user",
185 }, bot.PairingConfig{Enabled: true}); err != nil {
186 t.Fatalf("create pairing: %v", err)
187 }
188
189 out := captureStdout(t, func() {
190 if rc := botDoctor([]string{"--json", "--deep"}); rc != 0 {
191 t.Fatalf("botDoctor rc = %d, want 0", rc)
192 }
193 })
194 for _, want := range []string{
195 `"name":"bot.pairing.pending","status":"ok","detail":"1 pending"`,
196 `"name":"bot.roles","status":"ok","detail":"approvers=1 admins=1"`,
197 `"name":"bot.config.user","status":"ok"`,
198 } {
199 if !strings.Contains(out, want) {
200 t.Fatalf("bot doctor deep output missing %s:\n%s", want, out)
201 }
202 }
203 }
204
205 func TestBotPairingApproveAddsAllowlistAndFirstAdmin(t *testing.T) {
206 isolateBotUserConfig(t)
207 cfg := config.Default()
208 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
209 t.Fatalf("save config: %v", err)
210 }
211 req, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
212 Platform: bot.PlatformWeixin,
213 ChatType: bot.ChatDM,
214 ChatID: "wx-chat",
215 UserID: "wx-user",
216 }, bot.PairingConfig{Enabled: true})
217 if err != nil {
218 t.Fatalf("create pairing: %v", err)
219 }
220
221 if rc := botPairing([]string{"approve", req.Code}); rc != 0 {
222 t.Fatalf("botPairing approve rc = %d, want 0", rc)
223 }
224 got := config.LoadForEdit(config.UserConfigPath())
225 if users := got.Bot.Allowlist.WeixinUsers; len(users) != 1 || users[0] != "wx-user" {
226 t.Fatalf("weixin users = %+v, want wx-user", users)
227 }
228 if admins := got.Bot.Allowlist.WeixinAdmins; len(admins) != 1 || admins[0] != "wx-user" {
229 t.Fatalf("weixin admins = %+v, want first paired admin", admins)
230 }
231 if approvers := got.Bot.Allowlist.WeixinApprovers; len(approvers) != 1 || approvers[0] != "wx-user" {
232 t.Fatalf("weixin approvers = %+v, want first paired approver", approvers)
233 }
234 }
235
236 func TestBotPairingApproveAddsUserToConnectionAccess(t *testing.T) {
237 isolateBotUserConfig(t)
238 cfg := config.Default()
239 cfg.Bot.Connections = []config.BotConnectionConfig{{
240 ID: "feishu-lark",
241 Provider: "feishu",
242 Domain: "lark",
243 Label: "Lark",
244 Enabled: true,
245 Status: "connected",
246 Access: config.BotAccessConfig{
247 Enabled: true,
248 PairingEnabled: true,
249 Users: []string{"ou-existing"},
250 },
251 }}
252 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
253 t.Fatalf("save config: %v", err)
254 }
255 req, _, err := bot.CreateOrRefreshPairingRequest(bot.InboundMessage{
256 Platform: bot.PlatformFeishu,
257 ConnectionID: "feishu-lark",
258 Domain: "lark",
259 ChatType: bot.ChatDM,
260 ChatID: "oc-chat",
261 UserID: "ou-new",
262 }, bot.PairingConfig{Enabled: true})
263 if err != nil {
264 t.Fatalf("create pairing: %v", err)
265 }
266
267 if rc := botPairing([]string{"approve", req.Code}); rc != 0 {
268 t.Fatalf("botPairing approve rc = %d, want 0", rc)
269 }
270 got := config.LoadForEdit(config.UserConfigPath())
271 if users := got.Bot.Allowlist.FeishuUsers; len(users) != 0 {
272 t.Fatalf("global feishu users = %+v, want unchanged global allowlist", users)
273 }
274 if len(got.Bot.Connections) != 1 {
275 t.Fatalf("connections = %+v, want one connection", got.Bot.Connections)
276 }
277 access := got.Bot.Connections[0].Access
278 if !access.Enabled {
279 t.Fatal("connection access disabled after approval, want enabled")
280 }
281 for _, want := range []string{"ou-existing", "ou-new"} {
282 if !hasTestString(access.Users, want) {
283 t.Fatalf("connection users = %+v, want %s", access.Users, want)
284 }
285 }
286 }
287
288 func TestBotDoctorPrefersUserBotSettingsOverProjectBotConfig(t *testing.T) {
289 isolateBotUserConfig(t)
290 userCfg := config.Default()
291 userCfg.Bot.Enabled = true
292 userCfg.Bot.Allowlist.Enabled = true
293 userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-user"}
294 userCfg.Bot.Connections = []config.BotConnectionConfig{
295 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
296 }
297 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
298 t.Fatalf("save user config: %v", err)
299 }
300
301 project := t.TempDir()
302 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
303 [bot]
304 enabled = false
305 `), 0o644); err != nil {
306 t.Fatalf("write project config: %v", err)
307 }
308 t.Chdir(project)
309
310 out := captureStdout(t, func() {
311 if rc := botDoctor([]string{"--json"}); rc != 0 {
312 t.Fatalf("botDoctor rc = %d, want 0", rc)
313 }
314 })
315 for _, want := range []string{
316 `"name":"bot.enabled","status":"ok"`,
317 `"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
318 `"name":"bot.connection.feishu-lark.session_mappings","status":"missing","detail":"provider=feishu mappings=0"`,
319 } {
320 if !strings.Contains(out, want) {
321 t.Fatalf("bot doctor output missing %s:\n%s", want, out)
322 }
323 }
324 }
325
326 func TestBotDoctorUsesProjectBotConfigWhenUserBotIsUnconfigured(t *testing.T) {
327 isolateBotUserConfig(t)
328 projectCfg := config.Default()
329 projectCfg.Bot.Enabled = true
330 projectCfg.Bot.Allowlist.AllowAll = true
331 projectCfg.Bot.Connections = []config.BotConnectionConfig{
332 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Label: "微信", Enabled: true, Status: "connected"},
333 }
334 if err := projectCfg.SaveTo("reasonix.toml"); err != nil {
335 t.Fatalf("save project config: %v", err)
336 }
337
338 out := captureStdout(t, func() {
339 if rc := botDoctor([]string{"--json"}); rc != 0 {
340 t.Fatalf("botDoctor rc = %d, want 0", rc)
341 }
342 })
343 for _, want := range []string{
344 `"name":"bot.enabled","status":"ok"`,
345 `"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
346 `"name":"bot.allowlist","status":"open"`,
347 } {
348 if !strings.Contains(out, want) {
349 t.Fatalf("bot doctor output missing %s:\n%s", want, out)
350 }
351 }
352 }
353
354 func TestBotDoctorUsesProjectBotConfigWhenUserConfigOnlyHasBotDefaults(t *testing.T) {
355 isolateBotUserConfig(t)
356 userCfg := config.Default()
357 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
358 t.Fatalf("save user config: %v", err)
359 }
360 projectCfg := config.Default()
361 projectCfg.Bot.Enabled = true
362 projectCfg.Bot.Allowlist.AllowAll = true
363 projectCfg.Bot.Connections = []config.BotConnectionConfig{
364 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
365 }
366 if err := projectCfg.SaveTo("reasonix.toml"); err != nil {
367 t.Fatalf("save project config: %v", err)
368 }
369
370 out := captureStdout(t, func() {
371 if rc := botDoctor([]string{"--json"}); rc != 0 {
372 t.Fatalf("botDoctor rc = %d, want 0", rc)
373 }
374 })
375 for _, want := range []string{
376 `"name":"bot.enabled","status":"ok"`,
377 `"name":"bot.connections","status":"ok","detail":"enabled=1 total=1"`,
378 `"name":"bot.allowlist","status":"open"`,
379 } {
380 if !strings.Contains(out, want) {
381 t.Fatalf("bot doctor output missing %s:\n%s", want, out)
382 }
383 }
384 }
385
386 func TestBotConnectionChannelConfigsKeepFeishuAndLarkSeparate(t *testing.T) {
387 connections := []config.BotConnectionConfig{
388 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Model: "feishu-model", WorkspaceRoot: "/feishu"},
389 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Model: "lark-model", WorkspaceRoot: "/lark"},
390 }
391 channels := botruntime.ConnectionChannelConfigs(connections, true, true)
392 if channels["feishu-feishu"].Model != "feishu-model" || channels["feishu-feishu"].WorkspaceRoot != "/feishu" {
393 t.Fatalf("feishu channel = %+v, want feishu override", channels["feishu-feishu"])
394 }
395 if channels["feishu-lark"].Model != "lark-model" || channels["feishu-lark"].WorkspaceRoot != "/lark" {
396 t.Fatalf("lark channel = %+v, want lark override", channels["feishu-lark"])
397 }
398 }
399
400 func TestBotAdapterBindingsCreateSeparateFeishuAndLarkInstances(t *testing.T) {
401 cfg := config.Default()
402 cfg.Bot.Connections = []config.BotConnectionConfig{
403 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET"}},
404 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-lark", AppSecretEnv: "LARK_BOT_APP_SECRET"}},
405 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Credential: config.BotConnectionCredential{AccountID: "wx-account", TokenEnv: "WEIXIN_BOT_TOKEN"}},
406 }
407 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
408 bindings := botruntime.AdapterBindings(cfg, map[bot.Platform]bool{bot.PlatformFeishu: true, bot.PlatformWeixin: true}, nil, logger)
409
410 got := map[string]bot.AdapterBinding{}
411 for _, binding := range bindings {
412 got[binding.ID] = binding
413 }
414 for _, id := range []string{"feishu-feishu", "feishu-lark", "weixin-weixin"} {
415 if got[id].Adapter == nil {
416 t.Fatalf("binding %s missing from %+v", id, bindings)
417 }
418 }
419 if got["feishu-feishu"].Domain != "feishu" || got["feishu-lark"].Domain != "lark" {
420 t.Fatalf("domains = feishu:%q lark:%q, want separate domains", got["feishu-feishu"].Domain, got["feishu-lark"].Domain)
421 }
422 }
423
424 func TestBotAdapterBindingsIsolateRequestedFeishuDomain(t *testing.T) {
425 cfg := config.Default()
426 cfg.Bot.Connections = []config.BotConnectionConfig{
427 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-feishu", AppSecretEnv: "FEISHU_BOT_APP_SECRET"}},
428 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Credential: config.BotConnectionCredential{AppID: "cli-lark", AppSecretEnv: "LARK_BOT_APP_SECRET"}},
429 }
430 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
431 enabled := map[bot.Platform]bool{bot.PlatformFeishu: true}
432
433 larkOnly := botruntime.AdapterBindings(cfg, enabled, botruntime.RequestedFeishuDomains([]string{"lark"}), logger)
434 if len(larkOnly) != 1 || larkOnly[0].ID != "feishu-lark" {
435 t.Fatalf("--channels lark bindings = %+v, want only feishu-lark", larkOnly)
436 }
437
438 feishuOnly := botruntime.AdapterBindings(cfg, enabled, botruntime.RequestedFeishuDomains([]string{"feishu"}), logger)
439 if len(feishuOnly) != 1 || feishuOnly[0].ID != "feishu-feishu" {
440 t.Fatalf("--channels feishu bindings = %+v, want only feishu-feishu", feishuOnly)
441 }
442 }
443
444 func TestRememberBotInboundUsesConnectionID(t *testing.T) {
445 isolateBotUserConfig(t)
446 cfg := config.Default()
447 cfg.Bot.Connections = []config.BotConnectionConfig{
448 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Label: "飞书", Enabled: true, Status: "connected"},
449 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Label: "Lark", Enabled: true, Status: "connected"},
450 }
451 if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
452 t.Fatalf("save config: %v", err)
453 }
454
455 if err := botruntime.RememberInbound(bot.InboundMessage{
456 Platform: bot.PlatformFeishu,
457 ConnectionID: "feishu-lark",
458 Domain: "lark",
459 ChatType: bot.ChatDM,
460 ChatID: "oc-lark-chat",
461 UserID: "ou-lark-user",
462 }); err != nil {
463 t.Fatalf("rememberBotInbound: %v", err)
464 }
465
466 got := config.LoadForEdit(config.UserConfigPath())
467 var feishuConn, larkConn config.BotConnectionConfig
468 for _, conn := range got.Bot.Connections {
469 switch conn.ID {
470 case "feishu-feishu":
471 feishuConn = conn
472 case "feishu-lark":
473 larkConn = conn
474 }
475 }
476 if len(feishuConn.SessionMappings) != 0 {
477 t.Fatalf("feishu mappings = %+v, want none", feishuConn.SessionMappings)
478 }
479 if len(larkConn.SessionMappings) != 1 || larkConn.SessionMappings[0].RemoteID != "oc-lark-chat" {
480 t.Fatalf("lark mappings = %+v, want lark chat only", larkConn.SessionMappings)
481 }
482 }
483
484 func isolateBotUserConfig(t *testing.T) {
485 t.Helper()
486 home := t.TempDir()
487 t.Setenv("HOME", home)
488 t.Setenv("USERPROFILE", home)
489 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
490 t.Setenv("AppData", filepath.Join(home, "AppData"))
491 t.Chdir(t.TempDir())
492 }
493
494 func hasTestString(values []string, want string) bool {
495 for _, value := range values {
496 if value == want {
497 return true
498 }
499 }
500 return false
501 }
502
502 lines GO