505 lines
16 KiB
Go
505 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"reasonix/internal/bot"
|
|
"reasonix/internal/botruntime"
|
|
"reasonix/internal/config"
|
|
)
|
|
|
|
func TestDesktopBotRuntimePlanStartsSavedConnections(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.Bot.Enabled = true
|
|
cfg.Bot.Allowlist.Enabled = true
|
|
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
|
|
cfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
|
|
cfg.Bot.Connections = []config.BotConnectionConfig{
|
|
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true},
|
|
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
|
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true},
|
|
}
|
|
|
|
plan := desktopBotRuntimePlan(cfg)
|
|
if !plan.Start {
|
|
t.Fatalf("plan = %+v, want start", plan)
|
|
}
|
|
if !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
|
|
t.Fatalf("enabled = %+v, want feishu/lark and weixin platforms", plan.Enabled)
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.Bot.Enabled = true
|
|
cfg.Bot.Allowlist.Enabled = true
|
|
cfg.Bot.Pairing.Enabled = false
|
|
cfg.Bot.Connections = []config.BotConnectionConfig{
|
|
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
|
}
|
|
|
|
plan := desktopBotRuntimePlan(cfg)
|
|
if plan.Start || plan.Status != "blocked" {
|
|
t.Fatalf("plan = %+v, want blocked without allowlist", plan)
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.Bot.Enabled = true
|
|
cfg.Bot.Allowlist.Enabled = true
|
|
cfg.Bot.Pairing.Enabled = true
|
|
cfg.Bot.Connections = []config.BotConnectionConfig{
|
|
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
|
}
|
|
|
|
plan := desktopBotRuntimePlan(cfg)
|
|
if !plan.Start {
|
|
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
|
|
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
|
|
Model: "qq-model",
|
|
ToolApprovalMode: "auto",
|
|
WorkspaceRoot: "/tmp/qq-project",
|
|
}, nil, nil)
|
|
|
|
channel, ok := channels[bot.PlatformQQ]
|
|
if !ok {
|
|
t.Fatalf("platform QQ channel missing: %+v", channels)
|
|
}
|
|
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
|
|
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
|
|
}
|
|
connectionChannel, ok := connectionChannels["qq"]
|
|
if !ok {
|
|
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
|
|
}
|
|
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
|
|
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.Bot.Enabled = false
|
|
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
|
|
cfg.Bot.Connections = []config.BotConnectionConfig{
|
|
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
|
}
|
|
|
|
plan := desktopBotRuntimePlan(cfg)
|
|
if plan.Start || plan.Status != "stopped" {
|
|
t.Fatalf("plan = %+v, want stopped when disabled", plan)
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
|
|
cfg := config.Default()
|
|
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
|
ID: "feishu-lark",
|
|
Provider: "feishu",
|
|
Domain: "lark",
|
|
Enabled: true,
|
|
SessionMappings: []config.BotConnectionSessionMapping{
|
|
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
|
|
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
|
|
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
|
|
},
|
|
}}
|
|
|
|
targets := newDesktopBotRuntime().ForwardTargets(cfg)
|
|
if len(targets) != 2 {
|
|
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, target := range targets {
|
|
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
|
|
if seen[key] {
|
|
t.Fatalf("duplicate target %q in %+v", key, targets)
|
|
}
|
|
seen[key] = true
|
|
}
|
|
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
|
|
t.Fatalf("targets = %+v, want group and dm targets", targets)
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
userCfg := config.LoadForEdit(config.UserConfigPath())
|
|
userCfg.Bot.Enabled = true
|
|
userCfg.Bot.Allowlist.Enabled = true
|
|
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
|
|
userCfg.Bot.Connections = []config.BotConnectionConfig{
|
|
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Status: "connected"},
|
|
}
|
|
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
|
t.Fatalf("save user config: %v", err)
|
|
}
|
|
|
|
project := robustTempDir(t)
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
|
[bot]
|
|
enabled = false
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write project config: %v", err)
|
|
}
|
|
|
|
orig, _ := os.Getwd()
|
|
defer func() { _ = os.Chdir(orig) }()
|
|
if err := os.Chdir(project); err != nil {
|
|
t.Fatalf("chdir project: %v", err)
|
|
}
|
|
|
|
got, err := NewApp().loadDesktopBotConfig()
|
|
if err != nil {
|
|
t.Fatalf("load desktop bot config: %v", err)
|
|
}
|
|
plan := desktopBotRuntimePlan(got)
|
|
if !plan.Start || !plan.Enabled[bot.PlatformFeishu] {
|
|
t.Fatalf("desktop runtime plan = %+v, want user-level Lark connection to start", plan)
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
t.Cleanup(func() {
|
|
_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
|
|
_ = os.Unsetenv("LARK_BOT_APP_SECRET")
|
|
})
|
|
|
|
userCfg := config.Default()
|
|
userCfg.Bot.Enabled = true
|
|
userCfg.Bot.Allowlist.Enabled = true
|
|
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-feishu-installer", "ou-lark-installer"}
|
|
userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-installer"}
|
|
userCfg.Bot.Feishu.Enabled = true
|
|
userCfg.Bot.Weixin.Enabled = true
|
|
userCfg.Bot.Weixin.AccountID = "weixin-account"
|
|
userCfg.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
|
|
userCfg.Bot.Connections = []config.BotConnectionConfig{
|
|
{
|
|
ID: "feishu-feishu",
|
|
Provider: "feishu",
|
|
Domain: "feishu",
|
|
Enabled: true,
|
|
Status: "connected",
|
|
Credential: config.BotConnectionCredential{
|
|
AppID: "cli-feishu",
|
|
AppSecretEnv: "FEISHU_BOT_APP_SECRET",
|
|
},
|
|
},
|
|
{
|
|
ID: "feishu-lark",
|
|
Provider: "feishu",
|
|
Domain: "lark",
|
|
Enabled: true,
|
|
Status: "connected",
|
|
Credential: config.BotConnectionCredential{
|
|
AppID: "cli-lark",
|
|
AppSecretEnv: "LARK_BOT_APP_SECRET",
|
|
},
|
|
},
|
|
{
|
|
ID: "weixin-weixin",
|
|
Provider: "weixin",
|
|
Domain: "weixin",
|
|
Enabled: true,
|
|
Status: "connected",
|
|
Credential: config.BotConnectionCredential{
|
|
AccountID: "weixin-account",
|
|
TokenEnv: "WEIXIN_BOT_TOKEN",
|
|
},
|
|
},
|
|
}
|
|
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
|
t.Fatalf("save user config: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
|
|
t.Fatalf("create credentials dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(config.UserCredentialsPath(), []byte("FEISHU_BOT_APP_SECRET=feishu-secret\nLARK_BOT_APP_SECRET=lark-secret\n"), 0o600); err != nil {
|
|
t.Fatalf("write credentials: %v", err)
|
|
}
|
|
weixinAccountPath := filepath.Join(config.MemoryUserDir(), "weixin", "accounts", "weixin-account.json")
|
|
if err := os.MkdirAll(filepath.Dir(weixinAccountPath), 0o700); err != nil {
|
|
t.Fatalf("create weixin account dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(weixinAccountPath, []byte(`{"token":"weixin-token","base_url":"https://ilinkai.weixin.qq.com","user_id":"wx-installer"}`), 0o600); err != nil {
|
|
t.Fatalf("write weixin account: %v", err)
|
|
}
|
|
_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
|
|
_ = os.Unsetenv("LARK_BOT_APP_SECRET")
|
|
|
|
got, err := NewApp().loadDesktopBotConfig()
|
|
if err != nil {
|
|
t.Fatalf("load desktop bot config: %v", err)
|
|
}
|
|
views := botConnectionViews(got.Bot.Connections)
|
|
if len(views) != 3 {
|
|
t.Fatalf("connection views = %+v, want Feishu, Lark, and Weixin", views)
|
|
}
|
|
for _, view := range views {
|
|
if !view.Credential.SecretSet {
|
|
t.Fatalf("connection %s credential = %+v, want saved credential loaded after restart", view.ID, view.Credential)
|
|
}
|
|
}
|
|
plan := desktopBotRuntimePlan(got)
|
|
if !plan.Start || !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
|
|
t.Fatalf("desktop runtime plan = %+v, want saved Feishu/Lark/Weixin connections to start", plan)
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
bindings := botruntime.AdapterBindings(got, plan.Enabled, nil, logger)
|
|
if len(bindings) != 3 {
|
|
t.Fatalf("adapter bindings = %+v, want one per saved connection", bindings)
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
userCfg := config.Default()
|
|
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
|
t.Fatalf("set desktop appearance: %v", err)
|
|
}
|
|
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
|
t.Fatalf("save user config: %v", err)
|
|
}
|
|
|
|
project := robustTempDir(t)
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
|
[bot]
|
|
enabled = true
|
|
|
|
[bot.allowlist]
|
|
enabled = true
|
|
feishu_users = ["ou-legacy"]
|
|
|
|
[[bot.connections]]
|
|
id = "feishu-lark"
|
|
provider = "feishu"
|
|
domain = "lark"
|
|
label = "Lark"
|
|
enabled = true
|
|
status = "connected"
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write project config: %v", err)
|
|
}
|
|
|
|
orig, _ := os.Getwd()
|
|
defer func() { _ = os.Chdir(orig) }()
|
|
if err := os.Chdir(project); err != nil {
|
|
t.Fatalf("chdir project: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
got, err := app.loadDesktopBotConfig()
|
|
if err != nil {
|
|
t.Fatalf("load desktop bot config: %v", err)
|
|
}
|
|
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
|
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
|
}
|
|
|
|
// The bot-runtime load is a pure read: the merge above stays in memory and
|
|
// the user config file is not rewritten.
|
|
preWrite := config.LoadForEdit(config.UserConfigPath())
|
|
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
|
|
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
|
|
}
|
|
|
|
// The first locked write path performs the on-disk migration.
|
|
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
|
t.Fatalf("applyConfigOnly: %v", err)
|
|
}
|
|
persisted := config.LoadForEdit(config.UserConfigPath())
|
|
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
|
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
|
}
|
|
if persisted.DesktopTheme() != "dark" {
|
|
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
project := robustTempDir(t)
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
|
[desktop]
|
|
theme = "dark"
|
|
|
|
[bot]
|
|
enabled = true
|
|
|
|
[bot.allowlist]
|
|
enabled = true
|
|
feishu_users = ["ou-legacy"]
|
|
|
|
[[bot.connections]]
|
|
id = "feishu-lark"
|
|
provider = "feishu"
|
|
domain = "lark"
|
|
label = "Lark"
|
|
enabled = true
|
|
status = "connected"
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write project config: %v", err)
|
|
}
|
|
|
|
orig, _ := os.Getwd()
|
|
defer func() { _ = os.Chdir(orig) }()
|
|
if err := os.Chdir(project); err != nil {
|
|
t.Fatalf("chdir project: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
got, err := app.loadDesktopBotConfig()
|
|
if err != nil {
|
|
t.Fatalf("load desktop bot config: %v", err)
|
|
}
|
|
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
|
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
|
}
|
|
|
|
// The bot-runtime load is a pure read: it serves the legacy config from
|
|
// memory and must not create the user config file.
|
|
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
|
t.Fatalf("read path must not create the user config, stat err = %v", err)
|
|
}
|
|
|
|
// The first locked write path creates the user config with the migrated
|
|
// bot settings (adopting the legacy config, ConfigVersion-bumped).
|
|
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
|
t.Fatalf("applyConfigOnly: %v", err)
|
|
}
|
|
persisted := config.LoadForEdit(config.UserConfigPath())
|
|
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
|
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
|
}
|
|
}
|
|
|
|
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
project := robustTempDir(t)
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
|
[desktop]
|
|
theme = "dark"
|
|
close_behavior = "quit"
|
|
|
|
[bot]
|
|
enabled = true
|
|
|
|
[bot.allowlist]
|
|
enabled = true
|
|
feishu_users = ["ou-legacy"]
|
|
|
|
[[bot.connections]]
|
|
id = "feishu-lark"
|
|
provider = "feishu"
|
|
domain = "lark"
|
|
label = "Lark"
|
|
enabled = true
|
|
status = "connected"
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write project config: %v", err)
|
|
}
|
|
|
|
orig, _ := os.Getwd()
|
|
defer func() { _ = os.Chdir(orig) }()
|
|
if err := os.Chdir(project); err != nil {
|
|
t.Fatalf("chdir project: %v", err)
|
|
}
|
|
|
|
settings := NewApp().Settings()
|
|
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
|
|
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
|
|
}
|
|
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
|
|
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
|
|
}
|
|
|
|
persisted := config.LoadForEdit(config.UserConfigPath())
|
|
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
|
|
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
|
|
}
|
|
}
|
|
|
|
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
userCfg := config.Default()
|
|
userCfg.Bot.Enabled = true
|
|
userCfg.Bot.Allowlist.Enabled = true
|
|
userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
|
|
userCfg.Bot.Connections = []config.BotConnectionConfig{
|
|
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Status: "connected"},
|
|
}
|
|
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
|
t.Fatalf("save user config: %v", err)
|
|
}
|
|
|
|
project := robustTempDir(t)
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
|
[bot]
|
|
enabled = true
|
|
|
|
[bot.allowlist]
|
|
enabled = true
|
|
feishu_users = ["ou-legacy"]
|
|
|
|
[[bot.connections]]
|
|
id = "feishu-lark"
|
|
provider = "feishu"
|
|
domain = "lark"
|
|
enabled = true
|
|
status = "connected"
|
|
`), 0o644); err != nil {
|
|
t.Fatalf("write project config: %v", err)
|
|
}
|
|
|
|
orig, _ := os.Getwd()
|
|
defer func() { _ = os.Chdir(orig) }()
|
|
if err := os.Chdir(project); err != nil {
|
|
t.Fatalf("chdir project: %v", err)
|
|
}
|
|
|
|
got, err := NewApp().loadDesktopBotConfig()
|
|
if err != nil {
|
|
t.Fatalf("load desktop bot config: %v", err)
|
|
}
|
|
if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "weixin-weixin" {
|
|
t.Fatalf("desktop bot config = %+v, want existing user WeChat connection", got.Bot)
|
|
}
|
|
}
|
|
|
|
func TestSummarizeBotRuntimeErrorsCapsOutput(t *testing.T) {
|
|
got := summarizeBotRuntimeErrors([]error{
|
|
errors.New("first"),
|
|
nil,
|
|
errors.New("second"),
|
|
errors.New("third"),
|
|
errors.New("fourth"),
|
|
})
|
|
|
|
for _, want := range []string{"first", "second", "third", "1 more"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("summary = %q, want %q", got, want)
|
|
}
|
|
}
|
|
if strings.Contains(got, "fourth") {
|
|
t.Fatalf("summary = %q, should cap extra errors", got)
|
|
}
|
|
}
|