Files
2026-07-13 13:00:08 +08:00

334 lines
11 KiB
Go

package bot
import (
"context"
"fmt"
"strings"
"testing"
"reasonix/internal/event"
)
type fakeDesktopBridge struct {
sessions []DesktopSessionInfo
watching map[string]bool
approved []string
denied []string
answered map[string][]event.AskAnswer
questions map[string][]event.AskQuestion
takeovers map[string]string // routeKey -> tabID
driven []string
driveErr error
watchErr error
}
func newFakeDesktopBridge() *fakeDesktopBridge {
return &fakeDesktopBridge{
watching: make(map[string]bool),
answered: make(map[string][]event.AskAnswer),
questions: make(map[string][]event.AskQuestion),
takeovers: make(map[string]string),
}
}
func (f *fakeDesktopBridge) Sessions() []DesktopSessionInfo { return f.sessions }
func (f *fakeDesktopBridge) SetWatch(route DesktopWatchRoute, enable bool) error {
f.watching[route.Key()] = enable
return f.watchErr
}
func (f *fakeDesktopBridge) Watching(route DesktopWatchRoute) bool {
return f.watching[route.Key()]
}
func (f *fakeDesktopBridge) Approve(id string, allow bool) (string, error) {
if id == "gone" {
return "", fmt.Errorf("未找到待处理的审批 %s", id)
}
if allow {
f.approved = append(f.approved, id)
} else {
f.denied = append(f.denied, id)
}
return "已提交", nil
}
func (f *fakeDesktopBridge) AskQuestions(id string) ([]event.AskQuestion, bool) {
qs, ok := f.questions[id]
return qs, ok
}
func (f *fakeDesktopBridge) Answer(id string, answers []event.AskAnswer) (string, error) {
f.answered[id] = answers
return "已提交回答", nil
}
func (f *fakeDesktopBridge) Takeover(route DesktopWatchRoute, tabID string) (string, error) {
for _, s := range f.sessions {
if s.TabID == tabID {
f.takeovers[route.Key()] = tabID
return "已接管", nil
}
}
return "", fmt.Errorf("未找到会话 %s", tabID)
}
func (f *fakeDesktopBridge) Release(route DesktopWatchRoute) (string, error) {
if _, ok := f.takeovers[route.Key()]; !ok {
return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
}
delete(f.takeovers, route.Key())
return "已解除接管", nil
}
func (f *fakeDesktopBridge) TakeoverTab(route DesktopWatchRoute) string {
return f.takeovers[route.Key()]
}
func (f *fakeDesktopBridge) DriveInput(route DesktopWatchRoute, text string) (string, error) {
if f.driveErr != nil {
return "", f.driveErr
}
f.driven = append(f.driven, text)
return "", nil
}
func desktopTestMessage(text string) InboundMessage {
return InboundMessage{
Platform: PlatformFeishu,
ConnectionID: "feishu-main",
Domain: "feishu",
ChatType: ChatDM,
ChatID: "chat-god",
UserID: "admin-user",
Text: text,
}
}
func TestHandleDesktopCommandWithoutBridge(t *testing.T) {
gw := &BotGateway{cfg: GatewayConfig{}}
got := gw.handleDesktopCommand(desktopTestMessage("/desktop status"))
if !strings.Contains(got, "未运行在桌面端进程内") {
t.Fatalf("reply = %q, want standalone-mode notice", got)
}
}
func TestHandleDesktopCommandStatusListsSessions(t *testing.T) {
bridge := newFakeDesktopBridge()
bridge.sessions = []DesktopSessionInfo{
{TabID: "tab-1", Label: "修复登录", Workspace: "blade", Running: true, Ready: true},
{TabID: "tab-2", Label: "", Topic: "周报", Ready: true, PendingPrompt: true},
}
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
got := gw.handleDesktopCommand(desktopTestMessage("/desktop status"))
for _, want := range []string{"2 个", "修复登录", "▶️ 执行中", "周报", "⚠️ 等待审批/回答", "tab-1", "blade"} {
if !strings.Contains(got, want) {
t.Fatalf("status reply = %q, want it to contain %q", got, want)
}
}
}
func TestHandleDesktopCommandStatusListsPendingIDs(t *testing.T) {
bridge := newFakeDesktopBridge()
bridge.sessions = []DesktopSessionInfo{{
TabID: "tab-1", Label: "修复登录", Ready: true, PendingPrompt: true,
Pending: []DesktopPendingInfo{{ID: "appr-9", Kind: "approval", Tool: "bash"}},
}}
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
got := gw.handleDesktopCommand(desktopTestMessage("/desktop status"))
// The pending id must be visible so a user whose push was dropped can still
// run /desktop approve <id>.
if !strings.Contains(got, "appr-9") {
t.Fatalf("status = %q, want it to list the pending approval id", got)
}
}
func TestHandleDesktopCommandStatusEmpty(t *testing.T) {
gw := &BotGateway{cfg: GatewayConfig{Desktop: newFakeDesktopBridge()}}
got := gw.handleDesktopCommand(desktopTestMessage("/desktop"))
if !strings.Contains(got, "没有 live 会话") {
t.Fatalf("reply = %q, want empty-sessions notice", got)
}
}
func TestHandleDesktopCommandWatchLifecycle(t *testing.T) {
bridge := newFakeDesktopBridge()
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
msg := desktopTestMessage("/desktop watch on")
got := gw.handleDesktopCommand(msg)
if !strings.Contains(got, "已订阅") {
t.Fatalf("watch on reply = %q", got)
}
route := desktopRouteFromMessage(msg)
if !bridge.watching[route.Key()] {
t.Fatal("watch on did not subscribe the message route")
}
msg.Text = "/desktop watch off"
got = gw.handleDesktopCommand(msg)
if !strings.Contains(got, "已退订") {
t.Fatalf("watch off reply = %q", got)
}
if bridge.watching[route.Key()] {
t.Fatal("watch off did not unsubscribe the message route")
}
}
func TestHandleDesktopCommandWatchReportsPersistenceFailure(t *testing.T) {
bridge := newFakeDesktopBridge()
bridge.watchErr = fmt.Errorf("disk unavailable")
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
msg := desktopTestMessage("/desktop watch on")
got := gw.handleDesktopCommand(msg)
if !strings.Contains(got, "本次运行中订阅") || !strings.Contains(got, "保存订阅失败") {
t.Fatalf("watch persistence failure reply = %q", got)
}
if !bridge.Watching(desktopRouteFromMessage(msg)) {
t.Fatal("runtime subscription should remain active after persistence failure")
}
}
func TestHandleDesktopCommandApproveAndDeny(t *testing.T) {
bridge := newFakeDesktopBridge()
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve appr-1")); !strings.Contains(got, "已提交") {
t.Fatalf("approve reply = %q", got)
}
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop deny appr-2")); !strings.Contains(got, "已提交") {
t.Fatalf("deny reply = %q", got)
}
if len(bridge.approved) != 1 || bridge.approved[0] != "appr-1" {
t.Fatalf("approved = %v, want [appr-1]", bridge.approved)
}
if len(bridge.denied) != 1 || bridge.denied[0] != "appr-2" {
t.Fatalf("denied = %v, want [appr-2]", bridge.denied)
}
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve gone")); !strings.Contains(got, "未找到") {
t.Fatalf("missing-approval reply = %q", got)
}
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop approve")); got != desktopCommandUsage {
t.Fatalf("missing-arg reply = %q, want usage", got)
}
}
func TestHandleDesktopCommandAnswerParsesSelection(t *testing.T) {
bridge := newFakeDesktopBridge()
bridge.questions["ask-1"] = []event.AskQuestion{{
ID: "q1",
Prompt: "选一个",
Options: []event.AskOption{{Label: "方案 A"}, {Label: "方案 B"}},
}}
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
got := gw.handleDesktopCommand(desktopTestMessage("/desktop answer ask-1 2"))
if !strings.Contains(got, "已提交回答") {
t.Fatalf("answer reply = %q", got)
}
answers := bridge.answered["ask-1"]
if len(answers) != 1 || answers[0].QuestionID != "q1" {
t.Fatalf("answers = %+v, want one answer for q1", answers)
}
if len(answers[0].Selected) != 1 || answers[0].Selected[0] != "方案 B" {
t.Fatalf("selected = %v, want numeric index resolved to 方案 B", answers[0].Selected)
}
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop answer ask-gone 1")); !strings.Contains(got, "未找到") {
t.Fatalf("missing-ask reply = %q", got)
}
}
func TestHandleDesktopCommandTakeoverAndRelease(t *testing.T) {
bridge := newFakeDesktopBridge()
bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}}
gw := &BotGateway{cfg: GatewayConfig{Desktop: bridge}}
msg := desktopTestMessage("/desktop takeover tab-1")
if got := gw.handleDesktopCommand(msg); !strings.Contains(got, "已接管") {
t.Fatalf("takeover reply = %q", got)
}
route := desktopRouteFromMessage(msg)
if bridge.takeovers[route.Key()] != "tab-1" {
t.Fatalf("takeovers = %v, want route bound to tab-1", bridge.takeovers)
}
msg.Text = "/desktop release"
if got := gw.handleDesktopCommand(msg); !strings.Contains(got, "已解除") {
t.Fatalf("release reply = %q", got)
}
if got := gw.handleDesktopCommand(desktopTestMessage("/desktop takeover missing")); !strings.Contains(got, "未找到") {
t.Fatalf("missing-tab reply = %q", got)
}
}
func TestDivertToDesktopTakeover(t *testing.T) {
bridge := newFakeDesktopBridge()
bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}}
gw := &BotGateway{
cfg: GatewayConfig{Desktop: bridge},
logger: discardLogger(),
adapterHealth: map[string]*AdapterHealthSnapshot{},
}
adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
msg := desktopTestMessage("帮我跑一下测试")
// 未接管:不分流。
if gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
t.Fatal("message should not divert without a takeover binding")
}
route := desktopRouteFromMessage(msg)
bridge.takeovers[route.Key()] = "tab-1"
msg.Text = "/desktop status"
if gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
t.Fatal("slash commands must remain in the bot command path during takeover")
}
msg.Text = "帮我跑一下测试"
if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
t.Fatal("message should divert to the taken-over session")
}
if len(bridge.driven) != 1 || bridge.driven[0] != "帮我跑一下测试" {
t.Fatalf("driven = %v, want the plain message text", bridge.driven)
}
// 驱动失败:错误文案回给用户。
bridge.driveErr = fmt.Errorf("会话正在执行中")
if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
t.Fatal("drive failure should still consume the message")
}
sent := adapter.sentMessages()
if len(sent) == 0 || !strings.Contains(sent[len(sent)-1].Text, "正在执行中") {
t.Fatalf("sent = %+v, want drive error relayed to the chat", sent)
}
}
func TestDivertToDesktopTakeoverRevokesFormerAdmin(t *testing.T) {
bridge := newFakeDesktopBridge()
bridge.sessions = []DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}}
gw := &BotGateway{
cfg: GatewayConfig{
Desktop: bridge,
Allowlist: AllowlistConfig{Admins: map[Platform][]string{
PlatformFeishu: {"current-admin"},
}},
},
logger: discardLogger(),
adapterHealth: map[string]*AdapterHealthSnapshot{},
}
adapter := newFakeAdapter(PlatformFeishu, "fake-feishu")
msg := desktopTestMessage("run tests")
route := desktopRouteFromMessage(msg)
bridge.takeovers[route.Key()] = "tab-1"
if !gw.divertToDesktopTakeover(context.Background(), adapter, msg) {
t.Fatal("revoked takeover message should be consumed with an explanation")
}
if bridge.TakeoverTab(route) != "" || len(bridge.driven) != 0 {
t.Fatalf("revoked takeover remained active or drove input: tab=%q driven=%v", bridge.TakeoverTab(route), bridge.driven)
}
sent := adapter.sentMessages()
if len(sent) == 0 || !strings.Contains(sent[len(sent)-1].Text, "不再具有") {
t.Fatalf("sent = %+v, want admin-revocation explanation", sent)
}
}