379 lines
13 KiB
Go
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 explain terminal auto-confirmation only", out)
|
|
}
|
|
}
|