Files
dobotai-platform/internal/settings/controller_test.go
T

379 lines
13 KiB
Go

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|en|ja|ko|es>", "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/<BaseID>?iframeQuery=entrance%3Ddata%26sheetId%3D<SheetID>%26viewId%3D<ViewID>",
"完整保留",
"取消",
} {
if !strings.Contains(out, want) {
t.Fatalf("reply=%q must contain %q", out, want)
}
}
if strings.Contains(out, "i/nodes/<BaseID>?sheetId=<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 direct confirmation back to the server CLI only", out)
}
}