package settings import ( "context" "errors" "strings" "testing" "time" ) type memoryRepo struct { admins map[string]bool prefs map[string]string rounds map[string]int hints map[string]bool bindings map[string]ResourceBinding candidate BootstrapCandidate sessions map[string]SettingSession saveErr error } func newMemoryRepo() *memoryRepo { return &memoryRepo{admins: map[string]bool{}, prefs: map[string]string{}, rounds: map[string]int{}, hints: map[string]bool{}, bindings: map[string]ResourceBinding{}, sessions: map[string]SettingSession{}} } func (r *memoryRepo) IsAdministrator(_ context.Context, tenant, staff string) (bool, error) { return r.admins[tenant+"/"+staff], nil } func (r *memoryRepo) SetPreference(_ context.Context, tenant, channel, staff, locale string) error { r.prefs[tenant+"/"+staff] = locale return nil } func (r *memoryRepo) SetReasoningMaxRounds(_ context.Context, tenant, channel, staff string, rounds int, _ time.Time) error { r.rounds[tenant+"/"+channel+"/"+staff] = rounds return nil } func (r *memoryRepo) SetReasoningHintEnabled(_ context.Context, tenant, channel, staff string, enabled bool, _ time.Time) error { r.hints[tenant+"/"+channel+"/"+staff] = enabled return nil } func (r *memoryRepo) SaveBinding(_ context.Context, b ResourceBinding) error { if r.saveErr != nil { return r.saveErr } r.bindings[b.Kind] = b return nil } func (r *memoryRepo) TestAndActivateSupportGroup(_ context.Context, b ResourceBinding) error { b.Activate = true b.ValidationCode = "support_test_ok" r.bindings[b.Kind] = b return nil } func (r *memoryRepo) DetectBootstrapCandidate(_ context.Context, c BootstrapCandidate) error { r.candidate = c return nil } func (r *memoryRepo) GetSettingSession(_ context.Context, tenant, staff string) (SettingSession, bool, error) { s, ok := r.sessions[tenant+"/"+staff] return s, ok, nil } func (r *memoryRepo) SaveSettingSession(_ context.Context, s SettingSession) error { r.sessions[s.TenantKey+"/"+s.StaffID] = s return nil } func (r *memoryRepo) DeleteSettingSession(_ context.Context, tenant, staff string) error { delete(r.sessions, tenant+"/"+staff) return nil } func TestControllerLanguageForOrdinaryUser(t *testing.T) { r := newMemoryRepo() c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting language ja", ConversationType: "direct"}) if err != nil { t.Fatal(err) } if r.prefs["t/u"] != "ja" || out == "" { t.Fatalf("prefs=%v out=%q", r.prefs, out) } } func TestControllerReasoningRoundsForOrdinaryUser(t *testing.T) { r := newMemoryRepo() c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting rounds 15", ConversationType: "direct"}) if err != nil { t.Fatal(err) } if r.rounds["t/dingtalk/u"] != 15 || !strings.Contains(out, "15轮") { t.Fatalf("rounds=%v out=%q", r.rounds, out) } } func TestControllerRejectsOutOfRangeReasoningRounds(t *testing.T) { for _, value := range []string{"0", "51", "abc"} { r := newMemoryRepo() out, err := NewController(r).Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting rounds " + value, ConversationType: "direct"}) if err != nil { t.Fatal(err) } if len(r.rounds) != 0 || !strings.Contains(out, "1-50") { t.Fatalf("value=%q rounds=%v out=%q", value, r.rounds, out) } } } func TestControllerReasoningHintForOrdinaryUser(t *testing.T) { r := newMemoryRepo() c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting hint off", ConversationType: "direct"}) if err != nil { t.Fatal(err) } if r.hints["t/dingtalk/u"] || !strings.Contains(out, "已关闭") { t.Fatalf("hints=%v out=%q", r.hints, out) } } func TestControllerSettingMenuExplainsLanguageWithoutAdminActionsToOrdinaryUser(t *testing.T) { r := newMemoryRepo() c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/setting", ConversationType: "direct"}) if err != nil { t.Fatal(err) } for _, want := range []string{"个人设置", "/setting language ", "auto:跟随当前提问语言", "zh-CN:简体中文", "例如:/setting language en"} { if !strings.Contains(out, want) { t.Fatalf("reply=%q must contain %q", out, want) } } for _, forbidden := range []string{"系统管理", "/setting support", "/admin/settings", "DOBOTAI_ADMIN_TOKEN"} { if strings.Contains(out, forbidden) { t.Fatalf("ordinary-user reply=%q must not contain %q", out, forbidden) } } } func TestControllerSettingMenuExplainsAdminActionsWithoutBackendAccess(t *testing.T) { r := newMemoryRepo() r.admins["t/a"] = true c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "a", Text: "/setting", ConversationType: "direct"}) if err != nil { t.Fatal(err) } for _, want := range []string{ "系统管理(仅管理员)", "/setting support", "/setting support test", "/setting question", "/setting faq", "Schema验证通过后激活并短暂重启", } { if !strings.Contains(out, want) { t.Fatalf("reply=%q must contain %q", out, want) } } for _, forbidden := range []string{ "管理后台", "SSH隧道", "127.0.0.1:8090/admin/settings", "Basic Auth", "DOBOTAI_ADMIN_TOKEN", "回后台确认", } { if strings.Contains(out, forbidden) { t.Fatalf("reply=%q must not contain %q", out, forbidden) } } } func TestControllerRejectsOrdinaryUserSystemBinding(t *testing.T) { r := newMemoryRepo() c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "u", Text: "/setting support", ConversationType: "group", ConversationID: "g"}) if err != nil { t.Fatal(err) } if len(r.bindings) != 0 || out != "你没有系统管理权限。" { t.Fatalf("bindings=%v out=%q", r.bindings, out) } } func TestControllerBindsAdminSupportGroup(t *testing.T) { r := newMemoryRepo() r.admins["t/a"] = true c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "a", Text: "/setting support", ConversationType: "group", ConversationID: "g", ConversationTitle: "技术支持"}) if err != nil { t.Fatal(err) } binding := r.bindings["support_group"] if binding.ConversationID != "g" || binding.Activate { t.Fatalf("binding=%+v", binding) } if !strings.Contains(out, "support test") { t.Fatalf("reply=%q must explain explicit test command", out) } } func TestControllerExplicitlyTestsAndActivatesSupportGroup(t *testing.T) { r := newMemoryRepo() r.admins["t/a"] = true c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "a", Text: "/setting support test", ConversationType: "group", ConversationID: "g", ConversationTitle: "技术支持"}) if err != nil { t.Fatal(err) } binding := r.bindings["support_group"] if binding.ConversationID != "g" || !binding.Activate { t.Fatalf("binding=%+v", binding) } if !strings.Contains(out, "测试成功") || !strings.Contains(out, "已激活") { t.Fatalf("reply=%q", out) } } func TestControllerOnlyHandlesSettingEntrypoints(t *testing.T) { r := newMemoryRepo() c := NewController(r) if c.ShouldHandle(context.Background(), "t", "u", "/help") { t.Fatal("help must not enter the settings controller") } out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "u", Text: "/help", ConversationType: "direct"}) if err != nil { t.Fatal(err) } if out != "" { t.Fatalf("help reply=%q must be empty", out) } } func TestControllerChineseSettingAliasRunsSubcommands(t *testing.T) { r := newMemoryRepo() c := NewController(r) if !c.ShouldHandle(context.Background(), "t", "u", "/设置 language ja") { t.Fatal("Chinese setting alias must be handled") } out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "u", Text: "/设置 language ja", ConversationType: "direct"}) if err != nil { t.Fatal(err) } if r.prefs["t/u"] != "ja" || !strings.Contains(out, "ja") { t.Fatalf("pref=%q reply=%q", r.prefs["t/u"], out) } } func TestControllerParsesAdminQuestionTable(t *testing.T) { r := newMemoryRepo() r.admins["t/a"] = true c := NewController(r) _, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "a", Text: "/setting question https://alidocs.dingtalk.com/i/nodes/base?sheetId=sheet&viewId=view", ConversationType: "direct"}) if err != nil { t.Fatal(err) } if r.bindings["question_table"].BaseID != "base" { t.Fatalf("binding=%+v", r.bindings) } } func TestControllerAcceptsTableLinkInNextMessage(t *testing.T) { r := newMemoryRepo() r.admins["t/a"] = true c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "a", Text: "/setting question", ConversationType: "direct"}) if err != nil || !strings.Contains(out, "链接") { t.Fatalf("out=%q err=%v", out, err) } if !c.ShouldHandle(context.Background(), "t", "a", "https://alidocs.dingtalk.com/i/nodes/base?sheetId=sheet&viewId=view") { t.Fatal("active setting session must intercept next message") } _, err = c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "a", UnionID: "union", Text: "https://alidocs.dingtalk.com/i/nodes/base?sheetId=sheet&viewId=view", ConversationType: "direct"}) if err != nil { t.Fatal(err) } if r.bindings["question_table"].BaseID != "base" { t.Fatalf("binding=%+v", r.bindings) } if _, ok := r.sessions["t/a"]; ok { t.Fatal("setting session must be cleared") } } func TestControllerFAQBindingPromptExplainsPermissionsSchemaActivationAndLinkCards(t *testing.T) { r := newMemoryRepo() r.admins["t/a"] = true c := NewController(r) out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", Channel: "dingtalk", StaffID: "a", Text: "/setting faq", ConversationType: "direct"}) if err != nil { t.Fatal(err) } for _, want := range []string{ "10分钟", "链接卡片", "机器人应用", "管理员", "标准问题(必填)", "原始问题", "语言", "提问人", "立即激活FAQ写入", "短暂重启DobotAI", "只发送 alidocs.dingtalk.com/ 后面的部分", "i/nodes/?iframeQuery=entrance%3Ddata%26sheetId%3D%26viewId%3D", "完整保留", "取消", } { if !strings.Contains(out, want) { t.Fatalf("reply=%q must contain %q", out, want) } } if strings.Contains(out, "i/nodes/?sheetId=") { t.Fatalf("reply still contains obsolete direct-query example: %q", out) } } func TestControllerReportsFAQPermissionFailureAndKeepsWaitingSession(t *testing.T) { r := newMemoryRepo() r.admins["t/a"] = true c := NewController(r) if _, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "a", Text: "/setting faq", ConversationType: "direct"}); err != nil { t.Fatal(err) } r.saveErr = errors.New("schema_http_403") out, err := c.Handle(context.Background(), SettingInput{ TenantKey: "t", StaffID: "a", UnionID: "union", ConversationType: "direct", Text: "https://alidocs.dingtalk.com/i/nodes/base?sheetId=sheet&viewId=view", }) if err != nil { t.Fatalf("validation failure must become an actionable reply: %v", err) } for _, want := range []string{"table_permission_denied", "未激活", "机器人应用", "管理员", "重新发送链接卡片", "取消"} { if !strings.Contains(out, want) { t.Fatalf("reply=%q must contain %q", out, want) } } if _, ok := r.sessions["t/a"]; !ok { t.Fatal("failed validation must keep the setting session for retry") } if _, ok := r.bindings["faq_table"]; ok { t.Fatal("failed validation must not save a binding") } } func TestTableBindingFailureReplyUsesStableCodes(t *testing.T) { tests := []struct { err error code string }{ {errors.New("schema_http_403"), "table_permission_denied"}, {errors.New("schema_token_failed"), "table_permission_denied"}, {errors.New("missing_field"), "table_schema_missing_field"}, {errors.New("duplicate_field"), "table_schema_duplicate_field"}, {errors.New("field_type_mismatch"), "table_schema_field_type_mismatch"}, {errors.New("schema_network_failed"), "table_schema_network_failed"}, {errors.New("something private happened"), "table_validation_failed"}, } for _, tt := range tests { out := tableBindingFailureReply("faq", tt.err) if !strings.Contains(out, tt.code) { t.Fatalf("error=%q reply=%q want code=%q", tt.err, out, tt.code) } if tt.code == "table_validation_failed" && strings.Contains(out, tt.err.Error()) { t.Fatalf("reply leaked raw unknown error: %q", out) } } } func TestControllerDetectsBootstrapCandidate(t *testing.T) { r := newMemoryRepo() c := NewController(r) c.now = func() time.Time { return time.Unix(100, 0) } out, err := c.Handle(context.Background(), SettingInput{TenantKey: "t", StaffID: "a", UnionID: "union", DisplayName: "Alice", Text: "/setting 123456", ConversationType: "direct"}) if err != nil { t.Fatal(err) } if r.candidate.Code != "123456" || r.candidate.StaffID != "a" { t.Fatalf("candidate=%+v", r.candidate) } if !strings.Contains(out, "服务器终端将自动完成管理员绑定") || strings.Contains(out, "后台") { t.Fatalf("reply=%q must explain terminal auto-confirmation only", out) } }