| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | |
| 6 | "reasonix/internal/config" |
| 7 | ) |
| 8 | |
| 9 | // TestSetBotSettingsDingtalkRoundTrip 验证钉钉配置经 SetBotSettings 后能正确落盘并被读回。 |
| 10 | // 这是排查「钉钉配置界面几个字段写进去无法保存」的回归测试。 |
| 11 | func TestSetBotSettingsDingtalkRoundTrip(t *testing.T) { |
| 12 | isolateDesktopUserDirs(t) |
| 13 | cfg := config.Default() |
| 14 | cfg.Bot.Dingtalk = config.DingtalkBotConfig{ |
| 15 | Enabled: true, |
| 16 | ClientID: "appkey-initial", |
| 17 | SecretEnv: "DINGTALK_CLIENT_SECRET", |
| 18 | BotName: "InitialBot", |
| 19 | RequireMention: true, |
| 20 | SessionMappings: []config.BotConnectionSessionMapping{{ |
| 21 | RemoteID: "cid-preserved", |
| 22 | Scope: "global", |
| 23 | SessionID: "path:/sessions/preserved.jsonl", |
| 24 | SessionSource: "auto", |
| 25 | }}, |
| 26 | } |
| 27 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 28 | t.Fatalf("save initial: %v", err) |
| 29 | } |
| 30 | |
| 31 | app := NewApp() |
| 32 | view := botSettingsView(cfg.Bot) |
| 33 | view.Dingtalk.ClientID = "appkey-edited" |
| 34 | view.Dingtalk.BotName = "EditedBot" |
| 35 | view.Dingtalk.RequireMention = false |
| 36 | view.Dingtalk.ClientSecretEnv = "DINGTALK_CLIENT_SECRET" |
| 37 | if err := app.SetBotSettings(view); err != nil { |
| 38 | t.Fatalf("SetBotSettings: %v", err) |
| 39 | } |
| 40 | |
| 41 | got := config.LoadForEditWithoutCredentials(config.UserConfigPath()) |
| 42 | if got.Bot.Dingtalk.ClientID != "appkey-edited" { |
| 43 | t.Fatalf("clientId = %q, want appkey-edited", got.Bot.Dingtalk.ClientID) |
| 44 | } |
| 45 | if got.Bot.Dingtalk.BotName != "EditedBot" { |
| 46 | t.Fatalf("botName = %q, want EditedBot", got.Bot.Dingtalk.BotName) |
| 47 | } |
| 48 | if got.Bot.Dingtalk.RequireMention { |
| 49 | t.Fatalf("requireMention = true, want false") |
| 50 | } |
| 51 | if got.Bot.Dingtalk.SecretEnv != "DINGTALK_CLIENT_SECRET" { |
| 52 | t.Fatalf("secretEnv = %q, want DINGTALK_CLIENT_SECRET", got.Bot.Dingtalk.SecretEnv) |
| 53 | } |
| 54 | if mappings := got.Bot.Dingtalk.SessionMappings; len(mappings) != 1 || mappings[0].RemoteID != "cid-preserved" || mappings[0].SessionID != "path:/sessions/preserved.jsonl" { |
| 55 | t.Fatalf("session mappings = %+v, want hidden legacy mapping preserved", mappings) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // TestDesktopBotConfigConfiguredDetectsDingtalk 防止回归:用户只配置钉钉(其他平台 |
| 60 | // 均未配置)时,desktopBotConfigConfigured 必须返回 true,否则 legacy 合并路径会 |
| 61 | // 用 legacy config 整体覆盖 userCfg.Bot,导致钉钉 ClientID 等配置被丢弃 |
| 62 | // (表现为「输入 ClientID 后消失」)。 |
| 63 | func TestDesktopBotConfigConfiguredDetectsDingtalk(t *testing.T) { |
| 64 | cfg := config.Default() |
| 65 | cfg.Bot.Dingtalk.ClientID = "dingappkey123" |
| 66 | if !desktopBotConfigConfigured(cfg.Bot) { |
| 67 | t.Fatalf("dingtalk-only bot config must count as configured") |
| 68 | } |
| 69 | // 仅 SecretEnv 配了也应算已配置(环境变量形式的密钥)。 |
| 70 | cfg = config.Default() |
| 71 | cfg.Bot.Dingtalk.SecretEnv = "DINGTALK_CLIENT_SECRET" |
| 72 | if !desktopBotConfigConfigured(cfg.Bot) { |
| 73 | t.Fatalf("dingtalk secret-env-only config must count as configured") |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // TestDingtalkRuntimeConnectionID 验证测试发送时解析到真实运行的 connection id |
| 78 | // (自定义 id 如 dingtalk-test 必须命中),而不是硬编码退回 "dingtalk"。 |
| 79 | func TestDingtalkRuntimeConnectionID(t *testing.T) { |
| 80 | // 自定义 id:优先命中启用的钉钉连接。 |
| 81 | got := dingtalkRuntimeConnectionID([]config.BotConnectionConfig{ |
| 82 | {ID: "feishu-feishu", Provider: "feishu", Enabled: true}, |
| 83 | {ID: "dingtalk-test", Provider: "dingtalk", Enabled: true}, |
| 84 | }) |
| 85 | if got != "dingtalk-test" { |
| 86 | t.Fatalf("runtime connection id = %q, want dingtalk-test", got) |
| 87 | } |
| 88 | // 未启用:跳过。 |
| 89 | got = dingtalkRuntimeConnectionID([]config.BotConnectionConfig{ |
| 90 | {ID: "dingtalk-disabled", Provider: "dingtalk", Enabled: false}, |
| 91 | }) |
| 92 | if got != "dingtalk" { |
| 93 | t.Fatalf("disabled connection must be skipped, got %q", got) |
| 94 | } |
| 95 | // 无 id 的钉钉连接:退回 provider 名。 |
| 96 | got = dingtalkRuntimeConnectionID([]config.BotConnectionConfig{ |
| 97 | {Provider: "dingtalk", Enabled: true}, |
| 98 | }) |
| 99 | if got != "dingtalk" { |
| 100 | t.Fatalf("id-less connection should fall back to %q, got %q", "dingtalk", got) |
| 101 | } |
| 102 | // 无任何钉钉连接:退回 "dingtalk"(legacy [bot.dingtalk] 路径)。 |
| 103 | got = dingtalkRuntimeConnectionID(nil) |
| 104 | if got != "dingtalk" { |
| 105 | t.Fatalf("no connection should fall back to %q, got %q", "dingtalk", got) |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // TestDingtalkRuntimeConnectionResolvesCredentials 验证测试发送凭据从所选 |
| 110 | // connection 解析(而不是 legacy [bot.dingtalk] 块)——否则 connection 驱动的 |
| 111 | // 运行时在 legacy 块为空时误报 dingtalk_secret_missing。 |
| 112 | func TestDingtalkRuntimeConnectionResolvesCredentials(t *testing.T) { |
| 113 | conns := []config.BotConnectionConfig{ |
| 114 | {ID: "feishu-feishu", Provider: "feishu", Enabled: true}, |
| 115 | {ID: "dingtalk-main", Provider: "dingtalk", Enabled: true, |
| 116 | Credential: config.BotConnectionCredential{AppID: "app-from-conn", AppSecretEnv: "DINGTALK_CONN_SECRET"}}, |
| 117 | } |
| 118 | conn, id, ok := dingtalkRuntimeConnection(conns) |
| 119 | if !ok { |
| 120 | t.Fatal("enabled dingtalk connection not found") |
| 121 | } |
| 122 | if id != "dingtalk-main" { |
| 123 | t.Fatalf("runtime id = %q, want dingtalk-main", id) |
| 124 | } |
| 125 | if conn.Credential.AppID != "app-from-conn" || conn.Credential.AppSecretEnv != "DINGTALK_CONN_SECRET" { |
| 126 | t.Fatalf("resolved connection credentials = %q/%q, want app-from-conn/DINGTALK_CONN_SECRET", |
| 127 | conn.Credential.AppID, conn.Credential.AppSecretEnv) |
| 128 | } |
| 129 | // 未启用的钉钉连接不应命中。 |
| 130 | if _, _, ok := dingtalkRuntimeConnection([]config.BotConnectionConfig{ |
| 131 | {ID: "dingtalk-off", Provider: "dingtalk", Enabled: false}, |
| 132 | }); ok { |
| 133 | t.Fatal("disabled dingtalk connection must not be selected") |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // TestSetBotSettingsDingtalkRuntimeOptionsRoundTrip 验证 legacy [bot.dingtalk] |
| 138 | // 的模型/工具审批模式/工作目录经 SetBotSettings 落盘并被读回。这是排查 |
| 139 | // 「钉钉面板模型、权限切换无法保存」的回归测试:config 结构体与 settings |
| 140 | // view 都支持这些字段,但 render.go 漏写导致 SaveTo 丢弃(已被修复)。 |
| 141 | func TestSetBotSettingsDingtalkRuntimeOptionsRoundTrip(t *testing.T) { |
| 142 | isolateDesktopUserDirs(t) |
| 143 | cfg := config.Default() |
| 144 | cfg.Bot.Dingtalk = config.DingtalkBotConfig{ |
| 145 | Enabled: true, |
| 146 | ClientID: "ding-appkey", |
| 147 | ClientSecret: "secret", |
| 148 | SecretEnv: "DINGTALK_CLIENT_SECRET", |
| 149 | BotName: "安博特", |
| 150 | RequireMention: true, |
| 151 | Model: "deepseek-flash/deepseek-v4-flash", |
| 152 | ToolApprovalMode: "danger-full-access", |
| 153 | WorkspaceRoot: "/tmp/probe-ws", |
| 154 | } |
| 155 | if err := cfg.SaveTo(config.UserConfigPath()); err != nil { |
| 156 | t.Fatalf("save initial: %v", err) |
| 157 | } |
| 158 | |
| 159 | app := NewApp() |
| 160 | view := botSettingsView(cfg.Bot) |
| 161 | if view.Dingtalk.Model != "deepseek-flash/deepseek-v4-flash" { |
| 162 | t.Fatalf("view model = %q, want deepseek-flash", view.Dingtalk.Model) |
| 163 | } |
| 164 | if view.Dingtalk.ToolApprovalMode != "danger-full-access" { |
| 165 | t.Fatalf("view permission preset = %q, want danger-full-access", view.Dingtalk.ToolApprovalMode) |
| 166 | } |
| 167 | view.Dingtalk.Model = "deepseek-flash/deepseek-v4-flash" |
| 168 | view.Dingtalk.ToolApprovalMode = "danger-full-access" |
| 169 | view.Dingtalk.WorkspaceRoot = "/tmp/probe-ws" |
| 170 | if err := app.SetBotSettings(view); err != nil { |
| 171 | t.Fatalf("SetBotSettings: %v", err) |
| 172 | } |
| 173 | |
| 174 | got := config.LoadForEditWithoutCredentials(config.UserConfigPath()) |
| 175 | if got.Bot.Dingtalk.Model != "deepseek-flash/deepseek-v4-flash" { |
| 176 | t.Fatalf("model = %q, want deepseek-flash (render must persist)", got.Bot.Dingtalk.Model) |
| 177 | } |
| 178 | if got.Bot.Dingtalk.ToolApprovalMode != "danger-full-access" { |
| 179 | t.Fatalf("toolApprovalMode = %q, want danger-full-access (render must persist)", got.Bot.Dingtalk.ToolApprovalMode) |
| 180 | } |
| 181 | if got.Bot.Dingtalk.WorkspaceRoot != "/tmp/probe-ws" { |
| 182 | t.Fatalf("workspaceRoot = %q, want /tmp/probe-ws (render must persist)", got.Bot.Dingtalk.WorkspaceRoot) |
| 183 | } |
| 184 | // 原有字段不能丢。 |
| 185 | if got.Bot.Dingtalk.ClientSecret != "secret" { |
| 186 | t.Fatalf("clientSecret lost: %q", got.Bot.Dingtalk.ClientSecret) |
| 187 | } |
| 188 | if got.Bot.Dingtalk.BotName != "安博特" { |
| 189 | t.Fatalf("botName lost: %q", got.Bot.Dingtalk.BotName) |
| 190 | } |
| 191 | } |
| 192 |