Skip to content

Commit 2feec8e

Browse files
hz210claude
andcommitted
test: add coverage for bot chat and web chat session APIs
Brings internal/web from 77.2% to 80.0% and internal/bot from 73.6% to 85.7% to meet CI coverage thresholds. Co-Authored-By: Claude (claude-opus-4-6) <noreply@anthropic.com>
1 parent 0fd5999 commit 2feec8e

2 files changed

Lines changed: 577 additions & 0 deletions

File tree

internal/bot/llm_chat_test.go

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
package bot
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
"time"
12+
)
13+
14+
// --- LLM Chat/ChatStream with mock HTTP server ---
15+
16+
func TestLLMClient_Chat_Success(t *testing.T) {
17+
// Mock Anthropic API server
18+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
if r.Method != "POST" {
20+
t.Errorf("expected POST, got %s", r.Method)
21+
}
22+
if r.Header.Get("Content-Type") != "application/json" {
23+
t.Errorf("expected application/json content type")
24+
}
25+
if r.Header.Get("anthropic-version") != "2023-06-01" {
26+
t.Errorf("expected anthropic-version header")
27+
}
28+
29+
w.Header().Set("Content-Type", "application/json")
30+
json.NewEncoder(w).Encode(map[string]interface{}{
31+
"content": []map[string]string{
32+
{"type": "text", "text": "Hello from LLM!"},
33+
},
34+
})
35+
}))
36+
defer server.Close()
37+
38+
// Parse port from test server URL
39+
port := serverPort(t, server)
40+
41+
client := NewLLMClient(port, "default", "test-model")
42+
client.client = server.Client()
43+
44+
// Override the URL by using the test server's port
45+
history := []ChatMessage{{Role: "user", Content: "hello"}}
46+
resp, err := client.Chat(context.Background(), "system prompt", history)
47+
if err != nil {
48+
t.Fatalf("Chat failed: %v", err)
49+
}
50+
if resp != "Hello from LLM!" {
51+
t.Errorf("expected 'Hello from LLM!', got %q", resp)
52+
}
53+
}
54+
55+
func TestLLMClient_Chat_ErrorStatus(t *testing.T) {
56+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57+
w.WriteHeader(http.StatusInternalServerError)
58+
w.Write([]byte("internal error"))
59+
}))
60+
defer server.Close()
61+
62+
port := serverPort(t, server)
63+
client := NewLLMClient(port, "default", "test-model")
64+
65+
history := []ChatMessage{{Role: "user", Content: "hello"}}
66+
_, err := client.Chat(context.Background(), "system", history)
67+
if err == nil {
68+
t.Fatal("expected error for 500 status")
69+
}
70+
if !strings.Contains(err.Error(), "500") {
71+
t.Errorf("error should contain status code, got: %v", err)
72+
}
73+
}
74+
75+
func TestLLMClient_Chat_EmptyResponse(t *testing.T) {
76+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+
json.NewEncoder(w).Encode(map[string]interface{}{
78+
"content": []map[string]string{},
79+
})
80+
}))
81+
defer server.Close()
82+
83+
port := serverPort(t, server)
84+
client := NewLLMClient(port, "default", "test-model")
85+
86+
history := []ChatMessage{{Role: "user", Content: "hello"}}
87+
_, err := client.Chat(context.Background(), "system", history)
88+
if err == nil {
89+
t.Fatal("expected error for empty response")
90+
}
91+
if !strings.Contains(err.Error(), "empty response") {
92+
t.Errorf("expected 'empty response' error, got: %v", err)
93+
}
94+
}
95+
96+
func TestLLMClient_ChatStream_Success(t *testing.T) {
97+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98+
w.Header().Set("Content-Type", "text/event-stream")
99+
flusher, _ := w.(http.Flusher)
100+
events := []string{
101+
`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello"}}`,
102+
`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":" world"}}`,
103+
`data: [DONE]`,
104+
}
105+
for _, e := range events {
106+
fmt.Fprintln(w, e)
107+
if flusher != nil {
108+
flusher.Flush()
109+
}
110+
}
111+
}))
112+
defer server.Close()
113+
114+
port := serverPort(t, server)
115+
client := NewLLMClient(port, "default", "test-model")
116+
117+
var chunks []string
118+
history := []ChatMessage{{Role: "user", Content: "hello"}}
119+
err := client.ChatStream(context.Background(), "system", history, func(delta string) {
120+
chunks = append(chunks, delta)
121+
})
122+
if err != nil {
123+
t.Fatalf("ChatStream failed: %v", err)
124+
}
125+
if len(chunks) != 2 {
126+
t.Fatalf("expected 2 chunks, got %d", len(chunks))
127+
}
128+
if chunks[0] != "Hello" || chunks[1] != " world" {
129+
t.Errorf("unexpected chunks: %v", chunks)
130+
}
131+
}
132+
133+
func TestLLMClient_ChatStream_ErrorStatus(t *testing.T) {
134+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135+
w.WriteHeader(http.StatusBadRequest)
136+
w.Write([]byte("bad request"))
137+
}))
138+
defer server.Close()
139+
140+
port := serverPort(t, server)
141+
client := NewLLMClient(port, "default", "test-model")
142+
143+
history := []ChatMessage{{Role: "user", Content: "hello"}}
144+
err := client.ChatStream(context.Background(), "system", history, func(delta string) {})
145+
if err == nil {
146+
t.Fatal("expected error for 400 status")
147+
}
148+
}
149+
150+
// --- handleChat with LLM ---
151+
152+
func TestGateway_handleChat_WithLLM(t *testing.T) {
153+
// Start mock LLM server
154+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+
json.NewEncoder(w).Encode(map[string]interface{}{
156+
"content": []map[string]string{
157+
{"type": "text", "text": "I'm Zen, your assistant!"},
158+
},
159+
})
160+
}))
161+
defer server.Close()
162+
163+
port := serverPort(t, server)
164+
165+
g := newTestGateway()
166+
g.config.Profile = "default"
167+
g.config.ProxyPort = port
168+
g.config.MemoryDir = t.TempDir()
169+
g.llm = NewLLMClient(port, "default", "test-model")
170+
171+
adapter := newMockAdapter(PlatformTelegram)
172+
g.adapters = append(g.adapters, adapter)
173+
174+
session := g.sessions.GetOrCreate(PlatformTelegram, "user-1", "chat-1")
175+
intent := &ParsedIntent{Intent: IntentChat, Raw: "hello"}
176+
replyTo := ReplyContext{Platform: PlatformTelegram, ChatID: "chat-1"}
177+
178+
g.handleChat(intent, session, replyTo)
179+
180+
if len(adapter.sentMessages) != 1 {
181+
t.Fatalf("expected 1 message, got %d", len(adapter.sentMessages))
182+
}
183+
if !strings.Contains(adapter.sentMessages[0].Text, "Zen") {
184+
t.Errorf("expected response containing 'Zen', got: %s", adapter.sentMessages[0].Text)
185+
}
186+
}
187+
188+
func TestGateway_handleChat_LLMError(t *testing.T) {
189+
// Start mock server that returns errors
190+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191+
w.WriteHeader(http.StatusInternalServerError)
192+
w.Write([]byte("server error"))
193+
}))
194+
defer server.Close()
195+
196+
port := serverPort(t, server)
197+
198+
g := newTestGateway()
199+
g.config.Profile = "default"
200+
g.config.ProxyPort = port
201+
g.config.MemoryDir = t.TempDir()
202+
g.llm = NewLLMClient(port, "default", "test-model")
203+
204+
adapter := newMockAdapter(PlatformTelegram)
205+
g.adapters = append(g.adapters, adapter)
206+
207+
session := g.sessions.GetOrCreate(PlatformTelegram, "user-1", "chat-1")
208+
intent := &ParsedIntent{Intent: IntentChat, Raw: "hello"}
209+
replyTo := ReplyContext{Platform: PlatformTelegram, ChatID: "chat-1"}
210+
211+
g.handleChat(intent, session, replyTo)
212+
213+
// Should get fallback response on error
214+
if len(adapter.sentMessages) != 1 {
215+
t.Fatalf("expected 1 message, got %d", len(adapter.sentMessages))
216+
}
217+
}
218+
219+
func TestGateway_handleChat_WithTask(t *testing.T) {
220+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
221+
json.NewEncoder(w).Encode(map[string]interface{}{
222+
"content": []map[string]string{
223+
{"type": "text", "text": "Done!"},
224+
},
225+
})
226+
}))
227+
defer server.Close()
228+
229+
port := serverPort(t, server)
230+
g := newTestGateway()
231+
g.config.Profile = "default"
232+
g.config.ProxyPort = port
233+
g.config.MemoryDir = t.TempDir()
234+
g.llm = NewLLMClient(port, "default", "test-model")
235+
236+
adapter := newMockAdapter(PlatformTelegram)
237+
g.adapters = append(g.adapters, adapter)
238+
239+
session := g.sessions.GetOrCreate(PlatformTelegram, "user-1", "chat-1")
240+
// Intent with Task field set (takes priority over Raw)
241+
intent := &ParsedIntent{Intent: IntentChat, Raw: "original", Task: "specific task"}
242+
replyTo := ReplyContext{Platform: PlatformTelegram, ChatID: "chat-1"}
243+
244+
g.handleChat(intent, session, replyTo)
245+
246+
if len(adapter.sentMessages) != 1 {
247+
t.Fatalf("expected 1 message, got %d", len(adapter.sentMessages))
248+
}
249+
}
250+
251+
// --- UpdateStatusExtended ---
252+
253+
func TestClient_UpdateStatusExtended_NotConnected(t *testing.T) {
254+
client := NewClient("/path/to/project", "")
255+
err := client.UpdateStatusExtended(StatusUpdate{
256+
Status: "busy",
257+
CurrentTask: "running tests",
258+
})
259+
if err == nil {
260+
t.Error("UpdateStatusExtended should fail when not connected")
261+
}
262+
}
263+
264+
func TestClient_UpdateStatusExtended_TruncatesLongMessage(t *testing.T) {
265+
client := NewClient("/path/to/project", "")
266+
267+
longMsg := strings.Repeat("x", 300)
268+
update := StatusUpdate{
269+
Status: "busy",
270+
LastMessage: longMsg,
271+
}
272+
273+
// Call will fail (not connected), but we can verify truncation
274+
// by checking the cached status after the call
275+
_ = client.UpdateStatusExtended(update)
276+
277+
client.mu.Lock()
278+
cached := client.currentStatus
279+
client.mu.Unlock()
280+
281+
if len(cached.LastMessage) > 204 { // 200 + "..."
282+
t.Errorf("expected truncated message, got length %d", len(cached.LastMessage))
283+
}
284+
if !strings.HasSuffix(cached.LastMessage, "...") {
285+
t.Error("expected truncated message to end with '...'")
286+
}
287+
}
288+
289+
// --- formatProcessList edge cases ---
290+
291+
func TestFormatProcessList_WithAllFields(t *testing.T) {
292+
processes := []*ProcessInfo{
293+
{
294+
Name: "api",
295+
Path: "/path/to/api",
296+
Status: "busy",
297+
CurrentTask: "deploying",
298+
WaitingFor: "approval",
299+
PendingAction: "confirm deploy",
300+
LastMessage: "Waiting for user confirmation",
301+
MessageRole: "assistant",
302+
TurnCount: 5,
303+
StartTime: time.Now().Add(-1 * time.Hour),
304+
},
305+
}
306+
result := formatProcessList(processes)
307+
if !strings.Contains(result, "waiting for: approval") {
308+
t.Error("should contain waiting for")
309+
}
310+
if !strings.Contains(result, "pending action: confirm deploy") {
311+
t.Error("should contain pending action")
312+
}
313+
if !strings.Contains(result, "last assistant message") {
314+
t.Error("should contain last message with role")
315+
}
316+
if !strings.Contains(result, "turns: 5") {
317+
t.Error("should contain turn count")
318+
}
319+
}
320+
321+
func TestFormatProcessList_LongLastMessage(t *testing.T) {
322+
longMsg := strings.Repeat("a", 150)
323+
processes := []*ProcessInfo{
324+
{
325+
Name: "api",
326+
Path: "/p",
327+
Status: "idle",
328+
LastMessage: longMsg,
329+
StartTime: time.Now(),
330+
},
331+
}
332+
result := formatProcessList(processes)
333+
if !strings.Contains(result, "...") {
334+
t.Error("long message should be truncated with ...")
335+
}
336+
}
337+
338+
func TestFormatProcessList_EmptyMessageRole(t *testing.T) {
339+
processes := []*ProcessInfo{
340+
{
341+
Name: "api",
342+
Path: "/p",
343+
Status: "idle",
344+
LastMessage: "hello",
345+
MessageRole: "",
346+
StartTime: time.Now(),
347+
},
348+
}
349+
result := formatProcessList(processes)
350+
if !strings.Contains(result, "last unknown message") {
351+
t.Error("empty role should default to 'unknown'")
352+
}
353+
}
354+
355+
// --- helper ---
356+
357+
func serverPort(t *testing.T, server *httptest.Server) int {
358+
t.Helper()
359+
addr := server.Listener.Addr().String()
360+
var port int
361+
fmt.Sscanf(addr[strings.LastIndex(addr, ":")+1:], "%d", &port)
362+
return port
363+
}

0 commit comments

Comments
 (0)