返回 DeepSeek-Reasonix
bot_runtime_app_test.go
根目录 / desktop / bot_runtime_app_test.go
1 package main
2
3 import (
4 "errors"
5 "io"
6 "log/slog"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11
12 "reasonix/internal/bot"
13 "reasonix/internal/botruntime"
14 "reasonix/internal/config"
15 )
16
17 func TestDesktopBotRuntimePlanStartsSavedConnections(t *testing.T) {
18 cfg := config.Default()
19 cfg.Bot.Enabled = true
20 cfg.Bot.Allowlist.Enabled = true
21 cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
22 cfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
23 cfg.Bot.Connections = []config.BotConnectionConfig{
24 {ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true},
25 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
26 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true},
27 }
28
29 plan := desktopBotRuntimePlan(cfg)
30 if !plan.Start {
31 t.Fatalf("plan = %+v, want start", plan)
32 }
33 if !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
34 t.Fatalf("enabled = %+v, want feishu/lark and weixin platforms", plan.Enabled)
35 }
36 }
37
38 func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
39 cfg := config.Default()
40 cfg.Bot.Enabled = true
41 cfg.Bot.Allowlist.Enabled = true
42 cfg.Bot.Pairing.Enabled = false
43 cfg.Bot.Connections = []config.BotConnectionConfig{
44 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
45 }
46
47 plan := desktopBotRuntimePlan(cfg)
48 if plan.Start || plan.Status != "blocked" {
49 t.Fatalf("plan = %+v, want blocked without allowlist", plan)
50 }
51 }
52
53 func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
54 cfg := config.Default()
55 cfg.Bot.Enabled = true
56 cfg.Bot.Allowlist.Enabled = true
57 cfg.Bot.Pairing.Enabled = true
58 cfg.Bot.Connections = []config.BotConnectionConfig{
59 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
60 }
61
62 plan := desktopBotRuntimePlan(cfg)
63 if !plan.Start {
64 t.Fatalf("plan = %+v, want start with pairing enabled", plan)
65 }
66 }
67
68 func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
69 channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
70 Model: "qq-model",
71 ToolApprovalMode: "auto",
72 WorkspaceRoot: "/tmp/qq-project",
73 }, nil, nil)
74
75 channel, ok := channels[bot.PlatformQQ]
76 if !ok {
77 t.Fatalf("platform QQ channel missing: %+v", channels)
78 }
79 if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
80 t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
81 }
82 connectionChannel, ok := connectionChannels["qq"]
83 if !ok {
84 t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
85 }
86 if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
87 t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
88 }
89 }
90
91 func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
92 cfg := config.Default()
93 cfg.Bot.Enabled = false
94 cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
95 cfg.Bot.Connections = []config.BotConnectionConfig{
96 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
97 }
98
99 plan := desktopBotRuntimePlan(cfg)
100 if plan.Start || plan.Status != "stopped" {
101 t.Fatalf("plan = %+v, want stopped when disabled", plan)
102 }
103 }
104
105 func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
106 cfg := config.Default()
107 cfg.Bot.Connections = []config.BotConnectionConfig{{
108 ID: "feishu-lark",
109 Provider: "feishu",
110 Domain: "lark",
111 Enabled: true,
112 SessionMappings: []config.BotConnectionSessionMapping{
113 {RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
114 {RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
115 {RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
116 },
117 }}
118
119 targets := newDesktopBotRuntime().ForwardTargets(cfg)
120 if len(targets) != 2 {
121 t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
122 }
123 seen := map[string]bool{}
124 for _, target := range targets {
125 key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
126 if seen[key] {
127 t.Fatalf("duplicate target %q in %+v", key, targets)
128 }
129 seen[key] = true
130 }
131 if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
132 t.Fatalf("targets = %+v, want group and dm targets", targets)
133 }
134 }
135
136 func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
137 isolateDesktopUserDirs(t)
138
139 userCfg := config.LoadForEdit(config.UserConfigPath())
140 userCfg.Bot.Enabled = true
141 userCfg.Bot.Allowlist.Enabled = true
142 userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
143 userCfg.Bot.Connections = []config.BotConnectionConfig{
144 {ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Status: "connected"},
145 }
146 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
147 t.Fatalf("save user config: %v", err)
148 }
149
150 project := robustTempDir(t)
151 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
152 [bot]
153 enabled = false
154 `), 0o644); err != nil {
155 t.Fatalf("write project config: %v", err)
156 }
157
158 orig, _ := os.Getwd()
159 defer func() { _ = os.Chdir(orig) }()
160 if err := os.Chdir(project); err != nil {
161 t.Fatalf("chdir project: %v", err)
162 }
163
164 got, err := NewApp().loadDesktopBotConfig()
165 if err != nil {
166 t.Fatalf("load desktop bot config: %v", err)
167 }
168 plan := desktopBotRuntimePlan(got)
169 if !plan.Start || !plan.Enabled[bot.PlatformFeishu] {
170 t.Fatalf("desktop runtime plan = %+v, want user-level Lark connection to start", plan)
171 }
172 }
173
174 func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.T) {
175 isolateDesktopUserDirs(t)
176 t.Cleanup(func() {
177 _ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
178 _ = os.Unsetenv("LARK_BOT_APP_SECRET")
179 })
180
181 userCfg := config.Default()
182 userCfg.Bot.Enabled = true
183 userCfg.Bot.Allowlist.Enabled = true
184 userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-feishu-installer", "ou-lark-installer"}
185 userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-installer"}
186 userCfg.Bot.Feishu.Enabled = true
187 userCfg.Bot.Weixin.Enabled = true
188 userCfg.Bot.Weixin.AccountID = "weixin-account"
189 userCfg.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
190 userCfg.Bot.Connections = []config.BotConnectionConfig{
191 {
192 ID: "feishu-feishu",
193 Provider: "feishu",
194 Domain: "feishu",
195 Enabled: true,
196 Status: "connected",
197 Credential: config.BotConnectionCredential{
198 AppID: "cli-feishu",
199 AppSecretEnv: "FEISHU_BOT_APP_SECRET",
200 },
201 },
202 {
203 ID: "feishu-lark",
204 Provider: "feishu",
205 Domain: "lark",
206 Enabled: true,
207 Status: "connected",
208 Credential: config.BotConnectionCredential{
209 AppID: "cli-lark",
210 AppSecretEnv: "LARK_BOT_APP_SECRET",
211 },
212 },
213 {
214 ID: "weixin-weixin",
215 Provider: "weixin",
216 Domain: "weixin",
217 Enabled: true,
218 Status: "connected",
219 Credential: config.BotConnectionCredential{
220 AccountID: "weixin-account",
221 TokenEnv: "WEIXIN_BOT_TOKEN",
222 },
223 },
224 }
225 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
226 t.Fatalf("save user config: %v", err)
227 }
228 if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
229 t.Fatalf("create credentials dir: %v", err)
230 }
231 if err := os.WriteFile(config.UserCredentialsPath(), []byte("FEISHU_BOT_APP_SECRET=feishu-secret\nLARK_BOT_APP_SECRET=lark-secret\n"), 0o600); err != nil {
232 t.Fatalf("write credentials: %v", err)
233 }
234 weixinAccountPath := filepath.Join(config.MemoryUserDir(), "weixin", "accounts", "weixin-account.json")
235 if err := os.MkdirAll(filepath.Dir(weixinAccountPath), 0o700); err != nil {
236 t.Fatalf("create weixin account dir: %v", err)
237 }
238 if err := os.WriteFile(weixinAccountPath, []byte(`{"token":"weixin-token","base_url":"https://ilinkai.weixin.qq.com","user_id":"wx-installer"}`), 0o600); err != nil {
239 t.Fatalf("write weixin account: %v", err)
240 }
241 _ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
242 _ = os.Unsetenv("LARK_BOT_APP_SECRET")
243
244 got, err := NewApp().loadDesktopBotConfig()
245 if err != nil {
246 t.Fatalf("load desktop bot config: %v", err)
247 }
248 views := botConnectionViews(got.Bot.Connections)
249 if len(views) != 3 {
250 t.Fatalf("connection views = %+v, want Feishu, Lark, and Weixin", views)
251 }
252 for _, view := range views {
253 if !view.Credential.SecretSet {
254 t.Fatalf("connection %s credential = %+v, want saved credential loaded after restart", view.ID, view.Credential)
255 }
256 }
257 plan := desktopBotRuntimePlan(got)
258 if !plan.Start || !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
259 t.Fatalf("desktop runtime plan = %+v, want saved Feishu/Lark/Weixin connections to start", plan)
260 }
261 logger := slog.New(slog.NewTextHandler(io.Discard, nil))
262 bindings := botruntime.AdapterBindings(got, plan.Enabled, nil, logger)
263 if len(bindings) != 3 {
264 t.Fatalf("adapter bindings = %+v, want one per saved connection", bindings)
265 }
266 }
267
268 func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
269 isolateDesktopUserDirs(t)
270
271 userCfg := config.Default()
272 if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
273 t.Fatalf("set desktop appearance: %v", err)
274 }
275 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
276 t.Fatalf("save user config: %v", err)
277 }
278
279 project := robustTempDir(t)
280 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
281 [bot]
282 enabled = true
283
284 [bot.allowlist]
285 enabled = true
286 feishu_users = ["ou-legacy"]
287
288 [[bot.connections]]
289 id = "feishu-lark"
290 provider = "feishu"
291 domain = "lark"
292 label = "Lark"
293 enabled = true
294 status = "connected"
295 `), 0o644); err != nil {
296 t.Fatalf("write project config: %v", err)
297 }
298
299 orig, _ := os.Getwd()
300 defer func() { _ = os.Chdir(orig) }()
301 if err := os.Chdir(project); err != nil {
302 t.Fatalf("chdir project: %v", err)
303 }
304
305 app := NewApp()
306 got, err := app.loadDesktopBotConfig()
307 if err != nil {
308 t.Fatalf("load desktop bot config: %v", err)
309 }
310 if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
311 t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
312 }
313
314 // The bot-runtime load is a pure read: the merge above stays in memory and
315 // the user config file is not rewritten.
316 preWrite := config.LoadForEdit(config.UserConfigPath())
317 if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
318 t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
319 }
320
321 // The first locked write path performs the on-disk migration.
322 if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
323 t.Fatalf("applyConfigOnly: %v", err)
324 }
325 persisted := config.LoadForEdit(config.UserConfigPath())
326 if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
327 t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
328 }
329 if persisted.DesktopTheme() != "dark" {
330 t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
331 }
332 }
333
334 func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
335 isolateDesktopUserDirs(t)
336
337 project := robustTempDir(t)
338 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
339 [desktop]
340 theme = "dark"
341
342 [bot]
343 enabled = true
344
345 [bot.allowlist]
346 enabled = true
347 feishu_users = ["ou-legacy"]
348
349 [[bot.connections]]
350 id = "feishu-lark"
351 provider = "feishu"
352 domain = "lark"
353 label = "Lark"
354 enabled = true
355 status = "connected"
356 `), 0o644); err != nil {
357 t.Fatalf("write project config: %v", err)
358 }
359
360 orig, _ := os.Getwd()
361 defer func() { _ = os.Chdir(orig) }()
362 if err := os.Chdir(project); err != nil {
363 t.Fatalf("chdir project: %v", err)
364 }
365
366 app := NewApp()
367 got, err := app.loadDesktopBotConfig()
368 if err != nil {
369 t.Fatalf("load desktop bot config: %v", err)
370 }
371 if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
372 t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
373 }
374
375 // The bot-runtime load is a pure read: it serves the legacy config from
376 // memory and must not create the user config file.
377 if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
378 t.Fatalf("read path must not create the user config, stat err = %v", err)
379 }
380
381 // The first locked write path creates the user config with the migrated
382 // bot settings (adopting the legacy config, ConfigVersion-bumped).
383 if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
384 t.Fatalf("applyConfigOnly: %v", err)
385 }
386 persisted := config.LoadForEdit(config.UserConfigPath())
387 if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
388 t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
389 }
390 }
391
392 func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
393 isolateDesktopUserDirs(t)
394
395 project := robustTempDir(t)
396 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
397 [desktop]
398 theme = "dark"
399 close_behavior = "quit"
400
401 [bot]
402 enabled = true
403
404 [bot.allowlist]
405 enabled = true
406 feishu_users = ["ou-legacy"]
407
408 [[bot.connections]]
409 id = "feishu-lark"
410 provider = "feishu"
411 domain = "lark"
412 label = "Lark"
413 enabled = true
414 status = "connected"
415 `), 0o644); err != nil {
416 t.Fatalf("write project config: %v", err)
417 }
418
419 orig, _ := os.Getwd()
420 defer func() { _ = os.Chdir(orig) }()
421 if err := os.Chdir(project); err != nil {
422 t.Fatalf("chdir project: %v", err)
423 }
424
425 settings := NewApp().Settings()
426 if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
427 t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
428 }
429 if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
430 t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
431 }
432
433 persisted := config.LoadForEdit(config.UserConfigPath())
434 if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
435 t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
436 }
437 }
438
439 func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
440 isolateDesktopUserDirs(t)
441
442 userCfg := config.Default()
443 userCfg.Bot.Enabled = true
444 userCfg.Bot.Allowlist.Enabled = true
445 userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
446 userCfg.Bot.Connections = []config.BotConnectionConfig{
447 {ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Status: "connected"},
448 }
449 if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
450 t.Fatalf("save user config: %v", err)
451 }
452
453 project := robustTempDir(t)
454 if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
455 [bot]
456 enabled = true
457
458 [bot.allowlist]
459 enabled = true
460 feishu_users = ["ou-legacy"]
461
462 [[bot.connections]]
463 id = "feishu-lark"
464 provider = "feishu"
465 domain = "lark"
466 enabled = true
467 status = "connected"
468 `), 0o644); err != nil {
469 t.Fatalf("write project config: %v", err)
470 }
471
472 orig, _ := os.Getwd()
473 defer func() { _ = os.Chdir(orig) }()
474 if err := os.Chdir(project); err != nil {
475 t.Fatalf("chdir project: %v", err)
476 }
477
478 got, err := NewApp().loadDesktopBotConfig()
479 if err != nil {
480 t.Fatalf("load desktop bot config: %v", err)
481 }
482 if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "weixin-weixin" {
483 t.Fatalf("desktop bot config = %+v, want existing user WeChat connection", got.Bot)
484 }
485 }
486
487 func TestSummarizeBotRuntimeErrorsCapsOutput(t *testing.T) {
488 got := summarizeBotRuntimeErrors([]error{
489 errors.New("first"),
490 nil,
491 errors.New("second"),
492 errors.New("third"),
493 errors.New("fourth"),
494 })
495
496 for _, want := range []string{"first", "second", "third", "1 more"} {
497 if !strings.Contains(got, want) {
498 t.Fatalf("summary = %q, want %q", got, want)
499 }
500 }
501 if strings.Contains(got, "fourth") {
502 t.Fatalf("summary = %q, should cap extra errors", got)
503 }
504 }
505
505 lines GO