返回 DeepSeek-Reasonix
session_test.go
根目录 / internal / bot / session_test.go
1 package bot
2
3 import (
4 "testing"
5 "time"
6 )
7
8 func TestBuildSessionKey(t *testing.T) {
9 tests := []struct {
10 name string
11 src SessionSource
12 // DM 同 chat 不同 user 应返回相同 key
13 wantSame bool
14 src2 SessionSource
15 }{
16 {
17 name: "dm same chat different user",
18 src: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "user123", UserID: "a"},
19 src2: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "user123", UserID: "b"},
20 wantSame: true,
21 },
22 {
23 name: "dm different chat",
24 src: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "user123", UserID: "a"},
25 src2: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "user456", UserID: "a"},
26 wantSame: false,
27 },
28 {
29 name: "direct same chat different user",
30 src: SessionSource{Platform: PlatformQQ, ChatType: ChatDirect, ChatID: "guild123", UserID: "a"},
31 src2: SessionSource{Platform: PlatformQQ, ChatType: ChatDirect, ChatID: "guild123", UserID: "b"},
32 wantSame: true,
33 },
34 {
35 name: "direct distinct from dm",
36 src: SessionSource{Platform: PlatformQQ, ChatType: ChatDirect, ChatID: "shared", UserID: "a"},
37 src2: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "shared", UserID: "a"},
38 wantSame: false,
39 },
40 {
41 name: "group same chat different user",
42 src: SessionSource{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "group1", UserID: "a"},
43 src2: SessionSource{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "group1", UserID: "b"},
44 wantSame: false,
45 },
46 {
47 name: "group same user different chat",
48 src: SessionSource{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "group1", UserID: "a"},
49 src2: SessionSource{Platform: PlatformFeishu, ChatType: ChatGroup, ChatID: "group2", UserID: "a"},
50 wantSame: false,
51 },
52 {
53 name: "thread shared",
54 src: SessionSource{Platform: PlatformQQ, ChatType: ChatThread, ChatID: "ch1", ThreadID: "th1", UserID: "a"},
55 src2: SessionSource{Platform: PlatformQQ, ChatType: ChatThread, ChatID: "ch1", ThreadID: "th1", UserID: "b"},
56 wantSame: true,
57 },
58 {
59 name: "different platform same ids",
60 src: SessionSource{Platform: PlatformQQ, ChatType: ChatDM, ChatID: "123", UserID: "u1"},
61 src2: SessionSource{Platform: PlatformFeishu, ChatType: ChatDM, ChatID: "123", UserID: "u1"},
62 wantSame: false,
63 },
64 {
65 name: "same platform different connection",
66 src: SessionSource{Platform: PlatformFeishu, ConnectionID: "feishu-feishu", ChatType: ChatDM, ChatID: "123", UserID: "u1"},
67 src2: SessionSource{Platform: PlatformFeishu, ConnectionID: "feishu-lark", ChatType: ChatDM, ChatID: "123", UserID: "u1"},
68 wantSame: false,
69 },
70 }
71
72 for _, tt := range tests {
73 t.Run(tt.name, func(t *testing.T) {
74 k1 := BuildSessionKey(tt.src)
75 k2 := BuildSessionKey(tt.src2)
76 if tt.wantSame && k1 != k2 {
77 t.Errorf("want same key, got %s != %s", k1, k2)
78 }
79 if !tt.wantSame && k1 == k2 {
80 t.Errorf("want different keys, got %s == %s", k1, k2)
81 }
82 })
83 }
84 }
85
86 func TestIsSlashBypass(t *testing.T) {
87 tests := []struct {
88 text string
89 bypass bool
90 }{
91 {"/stop", true},
92 {"/stop extra args", true},
93 {"/new", true},
94 {"/reset", true},
95 {"/approve", true},
96 {"/deny", true},
97 {"/yolo", true},
98 {"/yolo on", true},
99 {"/mode yolo", true},
100 {"/status", true},
101 {"/help", true},
102 {"hello", false},
103 {"/unknown", false},
104 {"", false},
105 {" /stop", false}, // leading space means not a slash command
106 }
107
108 for _, tt := range tests {
109 got := IsSlashBypass(tt.text)
110 if got != tt.bypass {
111 t.Errorf("IsSlashBypass(%q) = %v, want %v", tt.text, got, tt.bypass)
112 }
113 }
114 }
115
116 func TestSessionManager_TryAcquire(t *testing.T) {
117 sm := NewSessionManager(100 * time.Millisecond)
118
119 msg := InboundMessage{Text: "hello", Platform: PlatformQQ, ChatType: ChatDM, ChatID: "c1", UserID: "u1"}
120 key := BuildSessionKey(msg.Session())
121
122 // 第一次获取成功
123 acquired, merged := sm.TryAcquire(key, msg)
124 if !acquired || merged {
125 t.Error("first acquire should succeed")
126 }
127
128 // 第二次获取应该排队
129 acquired, merged = sm.TryAcquire(key, InboundMessage{Text: "world"})
130 if acquired || !merged {
131 t.Error("second acquire should merge into queue")
132 }
133
134 // slash bypass 命令应绕过
135 acquired, merged = sm.TryAcquire(key, InboundMessage{Text: "/stop"})
136 if !acquired || merged {
137 t.Error("slash bypass should acquire immediately")
138 }
139
140 // 第一次 Release 返回排队消息
141 next := sm.Release(key)
142 if next == nil {
143 t.Fatal("expected queued message after first release")
144 }
145 if next.Text != "world" {
146 t.Errorf("merged text = %q, want %q", next.Text, "world")
147 }
148 }
149
150 func TestSessionManager_Debounce(t *testing.T) {
151 sm := NewSessionManager(200 * time.Millisecond)
152
153 msg := InboundMessage{Text: "first", Platform: PlatformQQ, ChatType: ChatDM, ChatID: "c1", UserID: "u1"}
154 key := BuildSessionKey(msg.Session())
155
156 acquired, _ := sm.TryAcquire(key, msg)
157 if !acquired {
158 t.Fatal("first acquire should succeed")
159 }
160
161 // 同 session 消息应合并
162 sm.TryAcquire(key, InboundMessage{Text: "second"})
163 // 在 debounce 窗口内发第三条
164 sm.TryAcquire(key, InboundMessage{Text: "third"})
165
166 next := sm.Release(key)
167 if next == nil {
168 t.Fatal("expected queued message after release")
169 }
170 // "second" 和 "third" 合并在队列里("first" 已作为 active 被处理)
171 if next.Text != "second\nthird" {
172 t.Errorf("merged = %q, want %q", next.Text, "second\nthird")
173 }
174 }
175
176 func TestSessionManager_ForceRelease(t *testing.T) {
177 sm := NewSessionManager(100 * time.Millisecond)
178
179 msg := InboundMessage{Text: "test", Platform: PlatformQQ, ChatType: ChatDM, ChatID: "c1", UserID: "u1"}
180 key := BuildSessionKey(msg.Session())
181
182 sm.TryAcquire(key, msg)
183 if !sm.IsActive(key) {
184 t.Error("should be active")
185 }
186
187 sm.ForceRelease(key)
188 if sm.IsActive(key) {
189 t.Error("should not be active after force release")
190 }
191 }
192
193 func TestSessionManagerRunIfIdleSerializesNewAdmission(t *testing.T) {
194 sm := NewSessionManager(100 * time.Millisecond)
195 msg := InboundMessage{Text: "test", Platform: PlatformQQ, ChatType: ChatDM, ChatID: "c1", UserID: "u1"}
196 key := BuildSessionKey(msg.Session())
197 entered := make(chan struct{})
198 release := make(chan struct{})
199 switchDone := make(chan bool, 1)
200 go func() {
201 switchDone <- sm.runIfIdle(key, func() bool {
202 close(entered)
203 <-release
204 return true
205 })
206 }()
207 <-entered
208
209 admitted := make(chan bool, 1)
210 go func() {
211 acquired, _ := sm.TryAcquire(key, msg)
212 admitted <- acquired
213 }()
214 select {
215 case acquired := <-admitted:
216 t.Fatalf("message admission completed during runtime switch: acquired=%v", acquired)
217 case <-time.After(50 * time.Millisecond):
218 }
219 close(release)
220 if !<-switchDone {
221 t.Fatal("idle runtime switch was rejected")
222 }
223 select {
224 case acquired := <-admitted:
225 if !acquired {
226 t.Fatal("message was not admitted after runtime switch")
227 }
228 case <-time.After(time.Second):
229 t.Fatal("message admission remained blocked after runtime switch")
230 }
231 }
232
233 func TestHashID(t *testing.T) {
234 h1 := hashID("user_12345")
235 h2 := hashID("user_12345")
236 h3 := hashID("user_67890")
237
238 if h1 != h2 {
239 t.Error("same input should produce same hash")
240 }
241 if h1 == h3 {
242 t.Error("different inputs should produce different hashes")
243 }
244 if hashID("") != "" {
245 t.Error("empty input should produce empty hash")
246 }
247 }
248
249 func TestInboundMessage_Session(t *testing.T) {
250 msg := InboundMessage{
251 Platform: PlatformQQ,
252 ConnectionID: "qq-main",
253 Domain: "qq",
254 ChatType: ChatDM,
255 ChatID: "chat1",
256 UserID: "user1",
257 ThreadID: "thread1",
258 }
259
260 src := msg.Session()
261 if src.Platform != PlatformQQ || src.ConnectionID != "qq-main" || src.Domain != "qq" || src.ChatType != ChatDM || src.ChatID != "chat1" || src.UserID != "user1" || src.ThreadID != "thread1" {
262 t.Error("Session() should copy all fields")
263 }
264 }
265
265 lines GO