diff --git a/internal/channels/feishu/bot_parse_test.go b/internal/channels/feishu/bot_parse_test.go
new file mode 100644
index 00000000..745f3ebc
--- /dev/null
+++ b/internal/channels/feishu/bot_parse_test.go
@@ -0,0 +1,290 @@
+package feishu
+
+import (
+ "testing"
+)
+
+// --- parseMessageContent ---
+
+func TestParseMessageContent_Text(t *testing.T) {
+ cases := []struct {
+ name string
+ raw string
+ want string
+ }{
+ {"normal text", `{"text":"hello world"}`, "hello world"},
+ {"empty raw", "", ""},
+ {"invalid json falls back", "not-json", "not-json"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := parseMessageContent(tc.raw, "text")
+ if got != tc.want {
+ t.Errorf("got %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestParseMessageContent_Image(t *testing.T) {
+ got := parseMessageContent(`{"image_key":"img_123"}`, "image")
+ if got != "[image]" {
+ t.Errorf("got %q, want \"[image]\"", got)
+ }
+}
+
+func TestParseMessageContent_File(t *testing.T) {
+ got := parseMessageContent(`{"file_key":"f1","file_name":"report.pdf"}`, "file")
+ if got != "[file: report.pdf]" {
+ t.Errorf("got %q, want \"[file: report.pdf]\"", got)
+ }
+}
+
+func TestParseMessageContent_File_NoName(t *testing.T) {
+ got := parseMessageContent("not-json", "file")
+ if got != "[file]" {
+ t.Errorf("got %q, want \"[file]\"", got)
+ }
+}
+
+func TestParseMessageContent_UnknownType(t *testing.T) {
+ got := parseMessageContent(`{}`, "sticker")
+ if got != "[sticker message]" {
+ t.Errorf("got %q, want \"[sticker message]\"", got)
+ }
+}
+
+// --- parsePostContent ---
+
+func TestParsePostContent_FlatFormat(t *testing.T) {
+ // Format 2: flat, "content" at top level
+ raw := `{"content":[[{"tag":"text","text":"hello"},{"tag":"text","text":" world"}]]}`
+ got := parsePostContent(raw)
+ if got != "hello world" {
+ t.Errorf("got %q, want %q", got, "hello world")
+ }
+}
+
+func TestParsePostContent_LanguageWrapped_ZhCN(t *testing.T) {
+ raw := `{"zh_cn":{"title":"Title","content":[[{"tag":"text","text":"你好"}]]}}`
+ got := parsePostContent(raw)
+ if got != "你好" {
+ t.Errorf("got %q, want %q", got, "你好")
+ }
+}
+
+func TestParsePostContent_LanguageWrapped_EnUS(t *testing.T) {
+ raw := `{"en_us":{"content":[[{"tag":"md","text":"**bold**"}]]}}`
+ got := parsePostContent(raw)
+ if got != "**bold**" {
+ t.Errorf("got %q, want %q", got, "**bold**")
+ }
+}
+
+func TestParsePostContent_MultiParagraph(t *testing.T) {
+ raw := `{"content":[[{"tag":"text","text":"line1"}],[{"tag":"text","text":"line2"}]]}`
+ got := parsePostContent(raw)
+ if got != "line1\nline2" {
+ t.Errorf("got %q, want %q", got, "line1\nline2")
+ }
+}
+
+func TestParsePostContent_AtTag(t *testing.T) {
+ raw := `{"content":[[{"tag":"at","user_name":"Alice"},{"tag":"text","text":" hi"}]]}`
+ got := parsePostContent(raw)
+ if got != "@Alice hi" {
+ t.Errorf("got %q, want %q", got, "@Alice hi")
+ }
+}
+
+func TestParsePostContent_LinkTag(t *testing.T) {
+ raw := `{"content":[[{"tag":"a","href":"https://example.com","text":"click"}]]}`
+ got := parsePostContent(raw)
+ if got != "[click](https://example.com)" {
+ t.Errorf("got %q, want %q", got, "[click](https://example.com)")
+ }
+}
+
+func TestParsePostContent_LinkTag_NoText(t *testing.T) {
+ raw := `{"content":[[{"tag":"a","href":"https://example.com"}]]}`
+ got := parsePostContent(raw)
+ if got != "https://example.com" {
+ t.Errorf("got %q, want %q", got, "https://example.com")
+ }
+}
+
+func TestParsePostContent_ImageTag(t *testing.T) {
+ raw := `{"content":[[{"tag":"img","image_key":"img_abc"}]]}`
+ got := parsePostContent(raw)
+ if got != "[image]" {
+ t.Errorf("got %q, want %q", got, "[image]")
+ }
+}
+
+func TestParsePostContent_InvalidJSON(t *testing.T) {
+ // Falls back to raw content
+ raw := "not-json"
+ got := parsePostContent(raw)
+ if got != raw {
+ t.Errorf("got %q, want %q", got, raw)
+ }
+}
+
+// --- resolvePostElements ---
+
+func TestResolvePostElements_FirstMapValueFallback(t *testing.T) {
+ // No zh_cn/en_us key — uses first map value as language wrapper
+ raw := `{"custom_lang":{"content":[[{"tag":"text","text":"fallback"}]]}}`
+ elems := resolvePostElements(raw)
+ if len(elems) == 0 {
+ t.Fatal("expected elements, got none")
+ }
+}
+
+func TestResolvePostElements_NoContent(t *testing.T) {
+ raw := `{"zh_cn":{"title":"only title"}}`
+ elems := resolvePostElements(raw)
+ if elems != nil {
+ t.Errorf("expected nil, got %v", elems)
+ }
+}
+
+// --- extractPostImageKeys ---
+
+func TestExtractPostImageKeys_Basic(t *testing.T) {
+ raw := `{"content":[[{"tag":"img","image_key":"img_001"},{"tag":"text","text":"caption"}],[{"tag":"img","image_key":"img_002"}]]}`
+ keys := extractPostImageKeys(raw)
+ if len(keys) != 2 {
+ t.Fatalf("expected 2 keys, got %d: %v", len(keys), keys)
+ }
+ if keys[0] != "img_001" || keys[1] != "img_002" {
+ t.Errorf("keys: got %v", keys)
+ }
+}
+
+func TestExtractPostImageKeys_Dedup(t *testing.T) {
+ raw := `{"content":[[{"tag":"img","image_key":"img_dup"}],[{"tag":"img","image_key":"img_dup"}]]}`
+ keys := extractPostImageKeys(raw)
+ if len(keys) != 1 {
+ t.Errorf("expected 1 deduped key, got %d: %v", len(keys), keys)
+ }
+}
+
+func TestExtractPostImageKeys_NoImages(t *testing.T) {
+ raw := `{"content":[[{"tag":"text","text":"no images"}]]}`
+ keys := extractPostImageKeys(raw)
+ if keys != nil {
+ t.Errorf("expected nil, got %v", keys)
+ }
+}
+
+// --- resolveMentions ---
+
+func TestResolveMentions_BotMentionStripped(t *testing.T) {
+ text := "@_user_1 hello"
+ mentions := []mentionInfo{{Key: "@_user_1", OpenID: "ou_bot123", Name: "Bot"}}
+ got := resolveMentions(text, mentions, "ou_bot123")
+ if got != "hello" {
+ t.Errorf("got %q, want %q", got, "hello")
+ }
+}
+
+func TestResolveMentions_UserMentionReplaced(t *testing.T) {
+ text := "hey @_user_1 how are you"
+ mentions := []mentionInfo{{Key: "@_user_1", OpenID: "ou_user999", Name: "Alice"}}
+ got := resolveMentions(text, mentions, "ou_bot123")
+ if got != "hey @Alice how are you" {
+ t.Errorf("got %q, want %q", got, "hey @Alice how are you")
+ }
+}
+
+func TestResolveMentions_EmptyKey_Skipped(t *testing.T) {
+ text := "hello"
+ mentions := []mentionInfo{{Key: "", OpenID: "ou_abc", Name: "Bob"}}
+ got := resolveMentions(text, mentions, "")
+ if got != "hello" {
+ t.Errorf("got %q, want %q", got, "hello")
+ }
+}
+
+func TestResolveMentions_NoBotID_AllMentionsReplacedWithName(t *testing.T) {
+ text := "@_user_1 and @_user_2"
+ mentions := []mentionInfo{
+ {Key: "@_user_1", OpenID: "ou_A", Name: "Alice"},
+ {Key: "@_user_2", OpenID: "ou_B", Name: "Bob"},
+ }
+ got := resolveMentions(text, mentions, "")
+ // botOpenID is empty: all mentions treated as bot → stripped
+ // (because the condition is: if botOpenID != "" && openID == botOpenID → strip, else → replace with name)
+ // When botOpenID == "", neither branch strips unless openID matches empty string.
+ // Both get replaced with @Name.
+ if got != "@Alice and @Bob" {
+ t.Errorf("got %q, want %q", got, "@Alice and @Bob")
+ }
+}
+
+// --- parseMessageEvent (via Channel) ---
+
+func TestParseMessageEvent_TextMessage(t *testing.T) {
+ ch := &Channel{}
+ ev := &MessageEvent{}
+ ev.Event.Message.MessageID = "om_abc"
+ ev.Event.Message.ChatID = "oc_chat_1"
+ ev.Event.Message.ChatType = "p2p"
+ ev.Event.Message.MessageType = "text"
+ ev.Event.Message.Content = `{"text":"hello"}`
+ ev.Event.Sender.SenderID.OpenID = "ou_sender1"
+
+ mc := ch.parseMessageEvent(ev)
+ if mc == nil {
+ t.Fatal("got nil messageContext")
+ }
+ if mc.Content != "hello" {
+ t.Errorf("content: got %q, want %q", mc.Content, "hello")
+ }
+ if mc.ChatID != "oc_chat_1" {
+ t.Errorf("ChatID: got %q", mc.ChatID)
+ }
+ if mc.SenderID != "ou_sender1" {
+ t.Errorf("SenderID: got %q", mc.SenderID)
+ }
+ if mc.MentionedBot {
+ t.Error("MentionedBot should be false for text with no mentions")
+ }
+}
+
+func TestParseMessageEvent_BotMentioned(t *testing.T) {
+ ch := &Channel{botOpenID: "ou_bot_xyz"}
+ ev := &MessageEvent{}
+ ev.Event.Message.MessageType = "text"
+ ev.Event.Message.Content = `{"text":"@_user_1 please help"}`
+ ev.Event.Message.Mentions = []EventMention{
+ {Key: "@_user_1", ID: struct {
+ OpenID string `json:"open_id"`
+ UserID string `json:"user_id"`
+ UnionID string `json:"union_id"`
+ }{OpenID: "ou_bot_xyz"}, Name: "MyBot"},
+ }
+
+ mc := ch.parseMessageEvent(ev)
+ if !mc.MentionedBot {
+ t.Error("MentionedBot should be true when bot's open_id is mentioned")
+ }
+ // Bot mention key should be stripped from content
+ if mc.Content == "@_user_1 please help" {
+ t.Error("bot mention key should be stripped from content")
+ }
+}
+
+func TestParseMessageEvent_NoSender(t *testing.T) {
+ ch := &Channel{}
+ ev := &MessageEvent{}
+ ev.Event.Message.MessageType = "text"
+ ev.Event.Message.Content = `{"text":"hi"}`
+ // Sender fields are zero-value
+
+ mc := ch.parseMessageEvent(ev)
+ if mc.SenderID != "" {
+ t.Errorf("SenderID: got %q, want empty", mc.SenderID)
+ }
+}
diff --git a/internal/channels/feishu/bot_policy_test.go b/internal/channels/feishu/bot_policy_test.go
new file mode 100644
index 00000000..d661c0a1
--- /dev/null
+++ b/internal/channels/feishu/bot_policy_test.go
@@ -0,0 +1,169 @@
+package feishu
+
+import (
+ "context"
+ "testing"
+)
+
+// --- isInGroupAllowList ---
+
+func TestIsInGroupAllowList_Match(t *testing.T) {
+ ch := &Channel{groupAllowList: []string{"ou_user_1", "ou_user_2"}}
+ if !ch.isInGroupAllowList("ou_user_1") {
+ t.Error("ou_user_1 should be in allowlist")
+ }
+}
+
+func TestIsInGroupAllowList_AtPrefix(t *testing.T) {
+ // Allow entries may have "@" prefix stripped
+ ch := &Channel{groupAllowList: []string{"@ou_user_3"}}
+ if !ch.isInGroupAllowList("ou_user_3") {
+ t.Error("ou_user_3 (with @ prefix stripped) should match")
+ }
+}
+
+func TestIsInGroupAllowList_NoMatch(t *testing.T) {
+ ch := &Channel{groupAllowList: []string{"ou_user_1"}}
+ if ch.isInGroupAllowList("ou_stranger") {
+ t.Error("ou_stranger should not be in allowlist")
+ }
+}
+
+func TestIsInGroupAllowList_Empty(t *testing.T) {
+ ch := &Channel{}
+ if ch.isInGroupAllowList("ou_anyone") {
+ t.Error("empty allowlist should never match")
+ }
+}
+
+// --- checkGroupPolicy ---
+
+func TestCheckGroupPolicy_Disabled(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.GroupPolicy = "disabled"
+ if ch.checkGroupPolicy(context.Background(), "ou_user", "oc_chat") {
+ t.Error("disabled policy should always return false")
+ }
+}
+
+func TestCheckGroupPolicy_Open(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.GroupPolicy = "open"
+ if !ch.checkGroupPolicy(context.Background(), "ou_user", "oc_chat") {
+ t.Error("open policy should always return true")
+ }
+}
+
+func TestCheckGroupPolicy_DefaultIsOpen(t *testing.T) {
+ // Empty policy defaults to "open"
+ ch := &Channel{}
+ ch.cfg.GroupPolicy = ""
+ if !ch.checkGroupPolicy(context.Background(), "ou_anyone", "oc_chat") {
+ t.Error("empty policy should default to open and return true")
+ }
+}
+
+func TestCheckGroupPolicy_Pairing_GroupAllowListBypasses(t *testing.T) {
+ // Under "pairing" policy, groupAllowList is checked FIRST before BaseChannel,
+ // so a matching entry returns true without requiring a BaseChannel.
+ ch := &Channel{groupAllowList: []string{"ou_vip"}}
+ ch.cfg.GroupPolicy = "pairing"
+ if !ch.checkGroupPolicy(context.Background(), "ou_vip", "oc_chat") {
+ t.Error("groupAllowList match should bypass pairing and return true")
+ }
+}
+
+// --- webhookPath ---
+
+func TestWebhookPath_Default(t *testing.T) {
+ ch := &Channel{}
+ got := ch.webhookPath()
+ if got != defaultWebhookPath {
+ t.Errorf("got %q, want %q", got, defaultWebhookPath)
+ }
+}
+
+func TestWebhookPath_Custom(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.WebhookPath = "/custom/path"
+ got := ch.webhookPath()
+ if got != "/custom/path" {
+ t.Errorf("got %q, want %q", got, "/custom/path")
+ }
+}
+
+// --- WebhookHandler ---
+
+func TestWebhookHandler_NonWebhookMode_ReturnsNil(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ConnectionMode = "websocket"
+ path, handler := ch.WebhookHandler()
+ if path != "" || handler != nil {
+ t.Error("WebhookHandler should return ('', nil) for non-webhook mode")
+ }
+}
+
+func TestWebhookHandler_WebhookWithPort_ReturnsNil(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ConnectionMode = "webhook"
+ ch.cfg.WebhookPort = 3001
+ path, handler := ch.WebhookHandler()
+ if path != "" || handler != nil {
+ t.Error("WebhookHandler should return ('', nil) when webhook_port > 0")
+ }
+}
+
+// --- OnReactionEvent ---
+
+func TestOnReactionEvent_Off(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ReactionLevel = "off"
+ // Should be a no-op — no panic, no error
+ err := ch.OnReactionEvent(context.Background(), "oc_chat", "", "thinking")
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestOnReactionEvent_EmptyMessageID(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ReactionLevel = "full"
+ // Empty messageID → early return
+ err := ch.OnReactionEvent(context.Background(), "oc_chat", "", "thinking")
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestOnReactionEvent_Minimal_NonTerminalIgnored(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ReactionLevel = "minimal"
+ // "thinking" is not terminal → should be ignored (no-op)
+ err := ch.OnReactionEvent(context.Background(), "oc_chat", "om_msg1", "thinking")
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+// --- ClearReaction ---
+
+func TestClearReaction_NoExistingReaction(t *testing.T) {
+ ch := &Channel{}
+ // No reaction stored → should be no-op
+ err := ch.ClearReaction(context.Background(), "oc_chat_no_reaction", "")
+ if err != nil {
+ t.Errorf("ClearReaction with no reaction should succeed: %v", err)
+ }
+}
+
+// --- sendChunkedText ---
+
+func TestSendChunkedText_ShortText_SingleChunk(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_chunk_1"}}`)
+
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+ err := ch.sendChunkedText(context.Background(), "oc_chat", "chat_id", "short message", 4000, "")
+ if err != nil {
+ t.Fatalf("sendChunkedText: %v", err)
+ }
+}
diff --git a/internal/channels/feishu/channel_media_send_test.go b/internal/channels/feishu/channel_media_send_test.go
new file mode 100644
index 00000000..59ea464a
--- /dev/null
+++ b/internal/channels/feishu/channel_media_send_test.go
@@ -0,0 +1,97 @@
+package feishu
+
+import (
+ "bytes"
+ "context"
+ "testing"
+)
+
+// sendImage, sendFile, sendMarkdownCard, downloadMessageResource, uploadImage, uploadFile
+// all delegate to LarkClient methods. These tests exercise the Channel-level wrappers.
+
+func TestChannelSendImage(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_img_1"}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ if err := ch.sendImage(context.Background(), "oc_chat_1", "chat_id", "img_key_abc", ""); err != nil {
+ t.Fatalf("sendImage: %v", err)
+ }
+}
+
+func TestChannelSendFile(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_file_1"}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ if err := ch.sendFile(context.Background(), "oc_chat_1", "chat_id", "file_key_abc", "file", ""); err != nil {
+ t.Fatalf("sendFile: %v", err)
+ }
+}
+
+func TestChannelSendFile_DefaultMsgType(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_file_2"}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ // Empty msgType → defaults to "file"
+ if err := ch.sendFile(context.Background(), "oc_chat_1", "chat_id", "file_key_abc", "", ""); err != nil {
+ t.Fatalf("sendFile (empty msgType): %v", err)
+ }
+}
+
+func TestChannelSendMarkdownCard(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_card_1"}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ if err := ch.sendMarkdownCard(context.Background(), "oc_chat_1", "chat_id", "**hello world**", "", nil); err != nil {
+ t.Fatalf("sendMarkdownCard: %v", err)
+ }
+}
+
+func TestChannelSendMarkdownCard_WithMetadata(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"message_id":"om_card_2"}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ meta := map[string]string{"key": "value"}
+ if err := ch.sendMarkdownCard(context.Background(), "oc_chat_1", "chat_id", "text", "", meta); err != nil {
+ t.Fatalf("sendMarkdownCard (metadata): %v", err)
+ }
+}
+
+func TestChannelDownloadMessageResource(t *testing.T) {
+ data := []byte("resource data")
+ srv := newBinaryMockServer(t, data, "application/octet-stream")
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ got, _, err := ch.downloadMessageResource(context.Background(), "om_msg1", "fk_123", "file")
+ if err != nil {
+ t.Fatalf("downloadMessageResource: %v", err)
+ }
+ if string(got) != string(data) {
+ t.Errorf("data mismatch: got %q, want %q", got, data)
+ }
+}
+
+func TestChannelUploadImage(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"image_key":"img_uploaded_ch"}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ key, err := ch.uploadImage(context.Background(), bytes.NewReader([]byte{0xFF, 0xD8, 0xFF}))
+ if err != nil {
+ t.Fatalf("uploadImage: %v", err)
+ }
+ if key != "img_uploaded_ch" {
+ t.Errorf("image_key: got %q, want img_uploaded_ch", key)
+ }
+}
+
+func TestChannelUploadFile(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"file_key":"file_uploaded_ch"}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ key, err := ch.uploadFile(context.Background(), bytes.NewReader([]byte("pdf data")), "test.pdf", "pdf", 0)
+ if err != nil {
+ t.Fatalf("uploadFile: %v", err)
+ }
+ if key != "file_uploaded_ch" {
+ t.Errorf("file_key: got %q, want file_uploaded_ch", key)
+ }
+}
diff --git a/internal/channels/feishu/coverage_boost_test.go b/internal/channels/feishu/coverage_boost_test.go
new file mode 100644
index 00000000..2168396d
--- /dev/null
+++ b/internal/channels/feishu/coverage_boost_test.go
@@ -0,0 +1,279 @@
+package feishu
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/nextlevelbuilder/goclaw/internal/bus"
+)
+
+// --- sendText error path ---
+
+func TestSendText_Error(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ err := ch.sendText(context.Background(), "oc_chat_1", "chat_id", "hello", "")
+ if err == nil {
+ t.Fatal("expected error for non-zero API code")
+ }
+}
+
+// --- sendMarkdownCard error path ---
+
+func TestSendMarkdownCard_Error(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ err := ch.sendMarkdownCard(context.Background(), "oc_chat_1", "chat_id", "text", "", nil)
+ if err == nil {
+ t.Fatal("expected error for non-zero API code")
+ }
+}
+
+// --- sendChunkedText error propagation ---
+
+func TestSendChunkedText_Error(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ err := ch.sendChunkedText(context.Background(), "oc_chat_1", "chat_id", "hello error test", 4000, "")
+ if err == nil {
+ t.Fatal("expected error propagated from sendText")
+ }
+}
+
+// --- sendImage error path ---
+
+func TestSendImage_Error(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ err := ch.sendImage(context.Background(), "oc_chat_1", "chat_id", "img_key_abc", "")
+ if err == nil {
+ t.Fatal("expected error for send image failure")
+ }
+}
+
+// --- sendFile error path ---
+
+func TestSendFile_Error(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"error","data":{}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ err := ch.sendFile(context.Background(), "oc_chat_1", "chat_id", "file_key_abc", "file", "")
+ if err == nil {
+ t.Fatal("expected error for send file failure")
+ }
+}
+
+// --- OnReactionEvent active state (thinking/tool) ---
+
+func TestOnReactionEvent_Thinking_AddsReaction(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"reaction_id":"rx_001"}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+ ch.cfg.ReactionLevel = "full"
+
+ err := ch.OnReactionEvent(context.Background(), "oc_chat_1", "om_msg_1", "thinking")
+ if err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestOnReactionEvent_Thinking_AlreadyHasReaction(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"reaction_id":"rx_001"}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+ ch.cfg.ReactionLevel = "full"
+
+ // First call stores the reaction
+ _ = ch.OnReactionEvent(context.Background(), "oc_chat_11", "om_msg_1", "thinking")
+
+ // Second call with same chatID: already loaded, should skip
+ err := ch.OnReactionEvent(context.Background(), "oc_chat_11", "om_msg_2", "thinking")
+ if err != nil {
+ t.Errorf("unexpected error on second thinking call: %v", err)
+ }
+}
+
+// --- removeTypingReaction with stored reaction ---
+
+func TestRemoveTypingReaction_WithStoredReaction(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ ch.reactions.Store("oc_chat_2", &reactionState{
+ messageID: "om_msg_2",
+ reactionID: "rx_stored_1",
+ })
+
+ err := ch.removeTypingReaction(context.Background(), "oc_chat_2")
+ if err != nil {
+ t.Errorf("removeTypingReaction: %v", err)
+ }
+}
+
+func TestRemoveTypingReaction_EmptyReactionID(t *testing.T) {
+ ch := &Channel{}
+ // Store state with empty reactionID — should be no-op after LoadAndDelete
+ ch.reactions.Store("oc_chat_3", &reactionState{
+ messageID: "om_msg_3",
+ reactionID: "",
+ })
+
+ err := ch.removeTypingReaction(context.Background(), "oc_chat_3")
+ if err != nil {
+ t.Errorf("unexpected error for empty reactionID: %v", err)
+ }
+}
+
+// --- ClearReaction with stored reaction ---
+
+func TestClearReaction_WithStoredReaction(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ ch.reactions.Store("oc_chat_4", &reactionState{
+ messageID: "om_msg_4",
+ reactionID: "rx_to_clear",
+ })
+
+ err := ch.ClearReaction(context.Background(), "oc_chat_4", "")
+ if err != nil {
+ t.Errorf("ClearReaction: %v", err)
+ }
+}
+
+// --- WebhookHandler returns handler in webhook mode with port=0 ---
+
+func TestWebhookHandler_WebhookNoPort_ReturnsHandler(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ConnectionMode = "webhook"
+ ch.cfg.WebhookPort = 0
+ ch.cfg.VerificationToken = "testtoken"
+
+ path, handler := ch.WebhookHandler()
+ if path == "" {
+ t.Error("expected non-empty path for webhook mode with no port")
+ }
+ if handler == nil {
+ t.Error("expected non-nil handler for webhook mode with no port")
+ }
+}
+
+func TestWebhookHandler_CustomPath_ReturnsHandler(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ConnectionMode = "webhook"
+ ch.cfg.WebhookPort = 0
+ ch.cfg.WebhookPath = "/custom/feishu"
+
+ path, handler := ch.WebhookHandler()
+ if path != "/custom/feishu" {
+ t.Errorf("expected /custom/feishu, got %q", path)
+ }
+ if handler == nil {
+ t.Error("expected non-nil handler")
+ }
+}
+
+// --- sendMediaAttachment ---
+
+// newDualResponseServer returns a server that gives resp1 on first non-token request,
+// resp2 on subsequent requests.
+func newDualResponseServer(t *testing.T, resp1, resp2 string) *httptest.Server {
+ t.Helper()
+ call := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ w.Write([]byte(`{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if call == 0 {
+ w.Write([]byte(resp1))
+ } else {
+ w.Write([]byte(resp2))
+ }
+ call++
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestSendMediaAttachment_Image(t *testing.T) {
+ srv := newDualResponseServer(t,
+ `{"code":0,"msg":"ok","data":{"image_key":"img_test_key"}}`,
+ `{"code":0,"msg":"ok","data":{"message_id":"om_img_x"}}`,
+ )
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ f, err := os.CreateTemp(t.TempDir(), "test_img_*.png")
+ if err != nil {
+ t.Fatalf("create temp: %v", err)
+ }
+ f.Write([]byte{0x89, 0x50, 0x4E, 0x47})
+ f.Close()
+
+ att := bus.MediaAttachment{URL: f.Name(), ContentType: "image/png"}
+ if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err != nil {
+ t.Errorf("sendMediaAttachment image: %v", err)
+ }
+}
+
+func TestSendMediaAttachment_EmptyURL(t *testing.T) {
+ ch := &Channel{}
+ att := bus.MediaAttachment{URL: "", ContentType: "image/png"}
+ if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err != nil {
+ t.Errorf("expected no error for empty URL: %v", err)
+ }
+}
+
+func TestSendMediaAttachment_DefaultFile(t *testing.T) {
+ srv := newDualResponseServer(t,
+ `{"code":0,"msg":"ok","data":{"file_key":"file_test_key"}}`,
+ `{"code":0,"msg":"ok","data":{"message_id":"om_file_x"}}`,
+ )
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ f, err := os.CreateTemp(t.TempDir(), "test_doc_*.pdf")
+ if err != nil {
+ t.Fatalf("create temp: %v", err)
+ }
+ f.Write([]byte("pdf data"))
+ f.Close()
+
+ att := bus.MediaAttachment{URL: f.Name(), ContentType: "application/pdf"}
+ if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err != nil {
+ t.Errorf("sendMediaAttachment file: %v", err)
+ }
+}
+
+func TestSendMediaAttachment_Video(t *testing.T) {
+ srv := newDualResponseServer(t,
+ `{"code":0,"msg":"ok","data":{"file_key":"video_test_key"}}`,
+ `{"code":0,"msg":"ok","data":{"message_id":"om_video_x"}}`,
+ )
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ f, err := os.CreateTemp(t.TempDir(), "test_vid_*.mp4")
+ if err != nil {
+ t.Fatalf("create temp: %v", err)
+ }
+ f.Write([]byte("video data"))
+ f.Close()
+
+ att := bus.MediaAttachment{URL: f.Name(), ContentType: "video/mp4"}
+ if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err != nil {
+ t.Errorf("sendMediaAttachment video: %v", err)
+ }
+}
+
+func TestSendMediaAttachment_FileNotFound(t *testing.T) {
+ ch := &Channel{}
+ att := bus.MediaAttachment{URL: "/nonexistent/path/file.pdf", ContentType: "application/pdf"}
+ if err := ch.sendMediaAttachment(context.Background(), "oc_chat", "chat_id", att, ""); err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}
diff --git a/internal/channels/feishu/factory_test.go b/internal/channels/feishu/factory_test.go
new file mode 100644
index 00000000..33768aa1
--- /dev/null
+++ b/internal/channels/feishu/factory_test.go
@@ -0,0 +1,143 @@
+package feishu
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestFactory_MissingAppID(t *testing.T) {
+ creds, _ := json.Marshal(map[string]string{"app_secret": "s"})
+ _, err := Factory("test", creds, nil, nil, nil)
+ if err == nil {
+ t.Fatal("expected error for missing app_id")
+ }
+}
+
+func TestFactory_MissingAppSecret(t *testing.T) {
+ creds, _ := json.Marshal(map[string]string{"app_id": "a"})
+ _, err := Factory("test", creds, nil, nil, nil)
+ if err == nil {
+ t.Fatal("expected error for missing app_secret")
+ }
+}
+
+func TestFactory_EmptyCreds(t *testing.T) {
+ _, err := Factory("test", nil, nil, nil, nil)
+ if err == nil {
+ t.Fatal("expected error for empty credentials")
+ }
+}
+
+func TestFactory_InvalidCredsJSON(t *testing.T) {
+ _, err := Factory("test", []byte("not-json{"), nil, nil, nil)
+ if err == nil {
+ t.Fatal("expected error for invalid credentials JSON")
+ }
+}
+
+func TestFactory_InvalidConfigJSON(t *testing.T) {
+ creds, _ := json.Marshal(map[string]string{"app_id": "a", "app_secret": "s"})
+ _, err := Factory("test", creds, []byte("not-json{"), nil, nil)
+ if err == nil {
+ t.Fatal("expected error for invalid config JSON")
+ }
+}
+
+func TestFactory_ValidMinimal(t *testing.T) {
+ creds, _ := json.Marshal(map[string]string{
+ "app_id": "cli_test_app",
+ "app_secret": "test_secret_123",
+ })
+ ch, err := Factory("feishu-test", creds, nil, nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch == nil {
+ t.Fatal("expected non-nil channel")
+ }
+}
+
+func TestFactory_ValidWithConfig(t *testing.T) {
+ creds, _ := json.Marshal(map[string]string{
+ "app_id": "cli_test_app",
+ "app_secret": "test_secret_123",
+ "verification_token": "vtok",
+ "encrypt_key": "ekey",
+ })
+ cfg, _ := json.Marshal(map[string]any{
+ "domain": "feishu",
+ "connection_mode": "webhook",
+ "dm_policy": "open",
+ "group_policy": "allowlist",
+ "text_chunk_limit": 2000,
+ "media_max_mb": 10,
+ })
+ ch, err := Factory("feishu-full", creds, cfg, nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch == nil {
+ t.Fatal("expected non-nil channel")
+ }
+}
+
+func TestFactory_DefaultGroupPolicy(t *testing.T) {
+ // When group_policy is empty in config, Factory should default to "pairing".
+ creds, _ := json.Marshal(map[string]string{
+ "app_id": "cli_test_app2",
+ "app_secret": "secret2",
+ })
+ cfg, _ := json.Marshal(map[string]any{}) // empty config
+ ch, err := Factory("feishu-default-policy", creds, cfg, nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch == nil {
+ t.Fatal("channel must not be nil")
+ }
+}
+
+func TestFactoryWithPendingStore_Valid(t *testing.T) {
+ factory := FactoryWithPendingStore(nil)
+ if factory == nil {
+ t.Fatal("FactoryWithPendingStore returned nil factory func")
+ }
+
+ creds, _ := json.Marshal(map[string]string{
+ "app_id": "cli_ws_app",
+ "app_secret": "ws_secret",
+ })
+ ch, err := factory("feishu-ws", creds, nil, nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch == nil {
+ t.Fatal("expected non-nil channel")
+ }
+}
+
+func TestFactoryWithPendingStore_MissingCreds(t *testing.T) {
+ factory := FactoryWithPendingStore(nil)
+ creds, _ := json.Marshal(map[string]string{"app_id": "only-id"})
+ _, err := factory("feishu-bad", creds, nil, nil, nil)
+ if err == nil {
+ t.Fatal("expected error for missing app_secret")
+ }
+}
+
+func TestFactoryWithPendingStore_InvalidCredsJSON(t *testing.T) {
+ factory := FactoryWithPendingStore(nil)
+ _, err := factory("feishu-bad", []byte("{bad"), nil, nil, nil)
+ if err == nil {
+ t.Fatal("expected error for invalid JSON")
+ }
+}
+
+func TestFactoryWithPendingStore_InvalidConfigJSON(t *testing.T) {
+ factory := FactoryWithPendingStore(nil)
+ creds, _ := json.Marshal(map[string]string{"app_id": "a", "app_secret": "b"})
+ _, err := factory("feishu-bad", creds, []byte("{bad"), nil, nil)
+ if err == nil {
+ t.Fatal("expected error for invalid config JSON")
+ }
+}
diff --git a/internal/channels/feishu/feishu_helpers_test.go b/internal/channels/feishu/feishu_helpers_test.go
new file mode 100644
index 00000000..b4c2194e
--- /dev/null
+++ b/internal/channels/feishu/feishu_helpers_test.go
@@ -0,0 +1,248 @@
+package feishu
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// --- resolveDomain ---
+
+func TestResolveDomain(t *testing.T) {
+ cases := []struct {
+ input string
+ want string
+ }{
+ {"feishu", "https://open.feishu.cn"},
+ {"lark", "https://open.larksuite.com"},
+ {"", "https://open.larksuite.com"},
+ {"https://custom.example.com", "https://custom.example.com"},
+ {"http://custom.example.com", "http://custom.example.com"},
+ {"custom.example.com", "https://custom.example.com"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.input, func(t *testing.T) {
+ got := resolveDomain(tc.input)
+ if got != tc.want {
+ t.Errorf("resolveDomain(%q) = %q, want %q", tc.input, got, tc.want)
+ }
+ })
+ }
+}
+
+// --- resolveReceiveIDType ---
+
+func TestResolveReceiveIDType(t *testing.T) {
+ cases := []struct {
+ id string
+ want string
+ }{
+ {"oc_chat123", "chat_id"},
+ {"ou_user456", "open_id"},
+ {"on_union789", "union_id"},
+ {"group:oc_chat123", "chat_id"}, // unknown prefix falls back to chat_id
+ {"", "chat_id"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.id, func(t *testing.T) {
+ got := resolveReceiveIDType(tc.id)
+ if got != tc.want {
+ t.Errorf("resolveReceiveIDType(%q) = %q, want %q", tc.id, got, tc.want)
+ }
+ })
+ }
+}
+
+// --- hasMentions ---
+
+func TestHasMentions(t *testing.T) {
+ cases := []struct {
+ text string
+ want bool
+ }{
+ {"hello @ou_abc123", true},
+ {"no mentions here", false},
+ {"@ou_xyz at start", true},
+ {"", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.text, func(t *testing.T) {
+ got := hasMentions(tc.text)
+ if got != tc.want {
+ t.Errorf("hasMentions(%q) = %v, want %v", tc.text, got, tc.want)
+ }
+ })
+ }
+}
+
+// --- convertMentionsForCard ---
+
+func TestConvertMentionsForCard(t *testing.T) {
+ input := "Hello @ou_abc123 and @ou_def456"
+ got := convertMentionsForCard(input)
+ if !strings.Contains(got, "") {
+ t.Errorf("missing first mention in: %q", got)
+ }
+ if !strings.Contains(got, "") {
+ t.Errorf("missing second mention in: %q", got)
+ }
+}
+
+func TestConvertMentionsForCard_NoMentions(t *testing.T) {
+ input := "plain text"
+ got := convertMentionsForCard(input)
+ if got != input {
+ t.Errorf("got %q, want %q", got, input)
+ }
+}
+
+// --- buildPostContent ---
+
+func TestBuildPostContent_NoMentions(t *testing.T) {
+ content := buildPostContent("simple text")
+ if !strings.Contains(content, "simple text") {
+ t.Errorf("content missing text: %q", content)
+ }
+ if !strings.Contains(content, "zh_cn") {
+ t.Errorf("content missing zh_cn wrapper: %q", content)
+ }
+ if !strings.Contains(content, `"tag":"md"`) {
+ t.Errorf("content missing md tag: %q", content)
+ }
+}
+
+func TestBuildPostContent_WithMention(t *testing.T) {
+ content := buildPostContent("Hey @ou_abc123 check this")
+ if !strings.Contains(content, `"tag":"at"`) {
+ t.Errorf("content missing at tag: %q", content)
+ }
+ if !strings.Contains(content, "ou_abc123") {
+ t.Errorf("content missing user_id: %q", content)
+ }
+}
+
+func TestBuildPostContent_MentionAtStart(t *testing.T) {
+ content := buildPostContent("@ou_abc123 please review")
+ if !strings.Contains(content, `"tag":"at"`) {
+ t.Errorf("content missing at tag: %q", content)
+ }
+ if !strings.Contains(content, "please review") {
+ t.Errorf("content missing trailing text: %q", content)
+ }
+}
+
+func TestBuildPostContent_MentionAtEnd(t *testing.T) {
+ content := buildPostContent("please review @ou_xyz999")
+ if !strings.Contains(content, "please review") {
+ t.Errorf("content missing leading text: %q", content)
+ }
+ if !strings.Contains(content, "ou_xyz999") {
+ t.Errorf("content missing user_id: %q", content)
+ }
+}
+
+// --- buildMarkdownCard ---
+
+func TestBuildMarkdownCard_Structure(t *testing.T) {
+ card := buildMarkdownCard("**hello**")
+ if card["schema"] != "2.0" {
+ t.Errorf("schema: got %v, want 2.0", card["schema"])
+ }
+ if card["config"] == nil {
+ t.Error("config must not be nil")
+ }
+ if card["body"] == nil {
+ t.Error("body must not be nil")
+ }
+}
+
+// --- shouldUseCard (package-level func: text → bool) ---
+
+func TestShouldUseCard(t *testing.T) {
+ cases := []struct {
+ text string
+ want bool
+ }{
+ {"```go\nfmt.Println()\n```", true},
+ {"| col1 | col2 |\n| --- | --- |", true},
+ {"|---|---|\n|a|b|", true},
+ {"plain text no code", false},
+ {"", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.text[:min(20, len(tc.text))], func(t *testing.T) {
+ got := shouldUseCard(tc.text)
+ if got != tc.want {
+ t.Errorf("shouldUseCard(%q) = %v, want %v", tc.text, got, tc.want)
+ }
+ })
+ }
+}
+
+// min is a local helper for Go < 1.21 compatibility in test labels.
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+// --- isDuplicate ---
+
+func TestIsDuplicate_FirstTimeFalse(t *testing.T) {
+ ch := &Channel{}
+ if ch.isDuplicate("msg_001") {
+ t.Error("first call must return false")
+ }
+}
+
+func TestIsDuplicate_SecondTimeTrue(t *testing.T) {
+ ch := &Channel{}
+ ch.isDuplicate("msg_002") // first call stores it
+ if !ch.isDuplicate("msg_002") {
+ t.Error("second call must return true")
+ }
+}
+
+func TestIsDuplicate_DifferentIDs(t *testing.T) {
+ ch := &Channel{}
+ ch.isDuplicate("msg_A")
+ if ch.isDuplicate("msg_B") {
+ t.Error("different IDs must not be duplicates")
+ }
+}
+
+// --- BlockReplyEnabled (returns *bool, cfg-level override) ---
+
+func TestBlockReplyEnabled_Default(t *testing.T) {
+ ch := &Channel{}
+ // nil pointer for BlockReply → returns nil
+ if result := ch.BlockReplyEnabled(); result != nil {
+ t.Errorf("BlockReplyEnabled should return nil by default, got %v", *result)
+ }
+}
+
+func TestBlockReplyEnabled_True(t *testing.T) {
+ b := true
+ ch := &Channel{cfg: config.FeishuConfig{
+ AppID: "test", AppSecret: "test",
+ BlockReply: &b,
+ }}
+ result := ch.BlockReplyEnabled()
+ if result == nil || !*result {
+ t.Error("BlockReplyEnabled should return pointer to true")
+ }
+}
+
+func TestBlockReplyEnabled_False(t *testing.T) {
+ b := false
+ ch := &Channel{cfg: config.FeishuConfig{
+ AppID: "test", AppSecret: "test",
+ BlockReply: &b,
+ }}
+ result := ch.BlockReplyEnabled()
+ if result == nil || *result {
+ t.Error("BlockReplyEnabled should return pointer to false")
+ }
+}
diff --git a/internal/channels/feishu/feishu_lifecycle_test.go b/internal/channels/feishu/feishu_lifecycle_test.go
new file mode 100644
index 00000000..aead8639
--- /dev/null
+++ b/internal/channels/feishu/feishu_lifecycle_test.go
@@ -0,0 +1,229 @@
+package feishu
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/config"
+)
+
+// --- wsEventAdapter.HandleEvent ---
+
+func TestWSEventAdapter_HandleEvent_InvalidJSON(t *testing.T) {
+ ch := &Channel{}
+ adapter := &wsEventAdapter{ch: ch}
+ err := adapter.HandleEvent(context.Background(), []byte("not-json"))
+ if err == nil {
+ t.Fatal("expected error for invalid JSON payload")
+ }
+}
+
+func TestWSEventAdapter_HandleEvent_NonMessageEvent_NoError(t *testing.T) {
+ ch := &Channel{}
+ adapter := &wsEventAdapter{ch: ch}
+ payload := []byte(`{"schema":"2.0","header":{"event_type":"im.chat.member.bot.added_v1"}}`)
+ err := adapter.HandleEvent(context.Background(), payload)
+ if err != nil {
+ t.Fatalf("non-message event should not error: %v", err)
+ }
+}
+
+// --- probeBotInfo ---
+
+func TestProbeBotInfo_Success(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"bot":{"open_id":"ou_bot_test"}}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ if err := ch.probeBotInfo(context.Background()); err != nil {
+ t.Fatalf("probeBotInfo error: %v", err)
+ }
+ if ch.botOpenID != "ou_bot_test" {
+ t.Errorf("botOpenID: got %q, want ou_bot_test", ch.botOpenID)
+ }
+}
+
+func TestProbeBotInfo_EmptyOpenID(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"bot":{"open_id":""}}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ if err := ch.probeBotInfo(context.Background()); err == nil {
+ t.Fatal("expected error for empty open_id")
+ }
+}
+
+func TestProbeBotInfo_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"forbidden","data":{}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ if err := ch.probeBotInfo(context.Background()); err == nil {
+ t.Fatal("expected error on API failure")
+ }
+}
+
+// --- SetPendingCompaction / SetPendingHistoryTenantID ---
+// Both use GroupHistory() which requires a real BaseChannel from New().
+
+func TestSetPendingCompaction_WithChannel(t *testing.T) {
+ ch := newLifecycleTestChannel(t)
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("SetPendingCompaction panicked: %v", r)
+ }
+ }()
+ ch.SetPendingCompaction(nil)
+}
+
+func TestSetPendingHistoryTenantID_WithChannel(t *testing.T) {
+ ch := newLifecycleTestChannel(t)
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("SetPendingHistoryTenantID panicked: %v", r)
+ }
+ }()
+ ch.SetPendingHistoryTenantID(uuid.UUID{})
+}
+
+// --- ListGroupMembers ---
+
+func TestListGroupMembers_Success(t *testing.T) {
+ respJSON := `{"code":0,"msg":"ok","data":{"items":[{"member_id":"ou_A","name":"Alice"},{"member_id":"ou_B","name":"Bob"}],"has_more":false}}`
+ srv := newSimpleMockServer(t, respJSON)
+ ch := newLifecycleTestChannel(t)
+ ch.client = NewLarkClient("app", "secret", srv.URL)
+
+ members, err := ch.ListGroupMembers(context.Background(), "oc_chat_1")
+ if err != nil {
+ t.Fatalf("ListGroupMembers error: %v", err)
+ }
+ if len(members) != 2 {
+ t.Errorf("expected 2 members, got %d", len(members))
+ }
+ if members[0].MemberID != "ou_A" {
+ t.Errorf("first member: got %q", members[0].MemberID)
+ }
+}
+
+func TestListGroupMembers_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"not found","data":{}}`)
+ ch := newLifecycleTestChannel(t)
+ ch.client = NewLarkClient("app", "secret", srv.URL)
+
+ if _, err := ch.ListGroupMembers(context.Background(), "oc_missing"); err == nil {
+ t.Fatal("expected error for API failure")
+ }
+}
+
+// --- resolveSenderName / fetchSenderName ---
+
+func TestResolveSenderName_EmptyOpenID(t *testing.T) {
+ ch := &Channel{}
+ if got := ch.resolveSenderName(context.Background(), ""); got != "" {
+ t.Errorf("empty openID should return empty name, got %q", got)
+ }
+}
+
+func TestResolveSenderName_CacheHit(t *testing.T) {
+ ch := &Channel{}
+ ch.senderCache.Store("ou_cached", &senderCacheEntry{
+ name: "Cached User",
+ expiresAt: time.Now().Add(10 * time.Minute),
+ })
+ if got := ch.resolveSenderName(context.Background(), "ou_cached"); got != "Cached User" {
+ t.Errorf("cache hit: got %q, want Cached User", got)
+ }
+}
+
+func TestResolveSenderName_ExpiredCacheFetchesAgain(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"user":{"name":"Fresh Name"}}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+ // Store an expired entry
+ ch.senderCache.Store("ou_expired", &senderCacheEntry{
+ name: "Stale Name",
+ expiresAt: time.Now().Add(-1 * time.Minute), // expired
+ })
+ got := ch.resolveSenderName(context.Background(), "ou_expired")
+ if got != "Fresh Name" {
+ t.Errorf("after cache expiry: got %q, want Fresh Name", got)
+ }
+}
+
+func TestFetchSenderName_Success(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"user":{"name":"Bob Jones"}}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ if got := ch.fetchSenderName(context.Background(), "ou_bob"); got != "Bob Jones" {
+ t.Errorf("fetchSenderName: got %q, want Bob Jones", got)
+ }
+}
+
+func TestFetchSenderName_APIError_ReturnsEmpty(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":50000,"msg":"not found","data":{}}`)
+ ch := &Channel{client: NewLarkClient("app", "secret", srv.URL)}
+
+ if got := ch.fetchSenderName(context.Background(), "ou_missing"); got != "" {
+ t.Errorf("fetchSenderName on error: got %q, want empty", got)
+ }
+}
+
+// --- larkws sendPing with nil conn (no-op) ---
+
+func TestLarkWSSendPing_NilConn(t *testing.T) {
+ c := NewWSClient("app", "secret", "http://localhost", nil)
+ c.mu.Lock()
+ c.stopCh = make(chan struct{})
+ c.mu.Unlock()
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("sendPing with nil conn panicked: %v", r)
+ }
+ }()
+ c.sendPing() // conn is nil → should be a no-op
+}
+
+// --- OnReactionEvent terminal states ---
+
+func TestOnReactionEvent_Done_RemovesReaction(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ReactionLevel = "full"
+ // "done" with no stored reaction → removeTypingReaction → no-op, no error
+ if err := ch.OnReactionEvent(context.Background(), "oc_chat", "om_msg", "done"); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestOnReactionEvent_ErrorStatus(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ReactionLevel = "full"
+ if err := ch.OnReactionEvent(context.Background(), "oc_chat", "om_msg", "error"); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+func TestOnReactionEvent_Minimal_TerminalAllowed(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.ReactionLevel = "minimal"
+ if err := ch.OnReactionEvent(context.Background(), "oc_chat", "om_msg", "done"); err != nil {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
+
+// --- test helpers ---
+
+// newLifecycleTestChannel creates a minimal valid Channel using New() so
+// BaseChannel is fully initialised. Tests that need BaseChannel methods
+// (GroupHistory, ContactCollector) should use this instead of &Channel{}.
+// Named distinctly from newTestChannel in commands_writers_test.go.
+func newLifecycleTestChannel(t *testing.T) *Channel {
+ t.Helper()
+ cfg := config.FeishuConfig{
+ AppID: "test-app-id",
+ AppSecret: "test-app-secret",
+ }
+ ch, err := New(cfg, nil, nil, nil)
+ if err != nil {
+ t.Fatalf("newTestChannel: New() error: %v", err)
+ }
+ return ch
+}
diff --git a/internal/channels/feishu/larkclient_contact_test.go b/internal/channels/feishu/larkclient_contact_test.go
new file mode 100644
index 00000000..0214a31d
--- /dev/null
+++ b/internal/channels/feishu/larkclient_contact_test.go
@@ -0,0 +1,83 @@
+package feishu
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+)
+
+// --- GetMessage ---
+
+func TestGetMessage_Success(t *testing.T) {
+ resp := map[string]any{
+ "code": 0, "msg": "ok",
+ "data": map[string]any{
+ "items": []any{
+ map[string]any{
+ "message_id": "om_abc123",
+ "msg_type": "text",
+ "body": map[string]any{"content": `{"text":"hello"}`},
+ "sender": map[string]any{"id": "ou_sender", "id_type": "open_id", "sender_type": "user"},
+ },
+ },
+ },
+ }
+ respJSON, _ := json.Marshal(resp)
+ srv := newSimpleMockServer(t, string(respJSON))
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ msg, err := c.GetMessage(context.Background(), "om_abc123")
+ if err != nil {
+ t.Fatalf("GetMessage error: %v", err)
+ }
+ if len(msg.Items) != 1 {
+ t.Fatalf("expected 1 item, got %d", len(msg.Items))
+ }
+ if msg.Items[0].MessageID != "om_abc123" {
+ t.Errorf("message_id: got %q", msg.Items[0].MessageID)
+ }
+}
+
+func TestGetMessage_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":230002,"msg":"message not found","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ _, err := c.GetMessage(context.Background(), "om_missing")
+ if err == nil {
+ t.Fatal("expected error for non-zero code")
+ }
+}
+
+// --- GetUser ---
+
+func TestGetUser_Success(t *testing.T) {
+ resp := map[string]any{
+ "code": 0, "msg": "ok",
+ "data": map[string]any{
+ "user": map[string]any{
+ "name": "Alice Smith",
+ },
+ },
+ }
+ respJSON, _ := json.Marshal(resp)
+ srv := newSimpleMockServer(t, string(respJSON))
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ name, err := c.GetUser(context.Background(), "ou_user_123", "open_id")
+ if err != nil {
+ t.Fatalf("GetUser error: %v", err)
+ }
+ if name != "Alice Smith" {
+ t.Errorf("name: got %q, want %q", name, "Alice Smith")
+ }
+}
+
+func TestGetUser_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":50000,"msg":"user not found","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ _, err := c.GetUser(context.Background(), "ou_missing", "open_id")
+ if err == nil {
+ t.Fatal("expected error for non-zero code")
+ }
+}
diff --git a/internal/channels/feishu/larkclient_http_test.go b/internal/channels/feishu/larkclient_http_test.go
new file mode 100644
index 00000000..ddc2273f
--- /dev/null
+++ b/internal/channels/feishu/larkclient_http_test.go
@@ -0,0 +1,239 @@
+package feishu
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// --- clearToken ---
+
+func TestClearToken_ResetsFields(t *testing.T) {
+ c := NewLarkClient("app", "secret", "http://localhost")
+ c.token = "some-token"
+ c.clearToken()
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.token != "" {
+ t.Errorf("token: got %q, want empty", c.token)
+ }
+ if !c.tokenExp.IsZero() {
+ t.Errorf("tokenExp: got %v, want zero", c.tokenExp)
+ }
+}
+
+// --- isTokenError ---
+
+func TestIsTokenError(t *testing.T) {
+ cases := []struct {
+ code int
+ want bool
+ }{
+ {99991663, true},
+ {99991664, true},
+ {99991671, true},
+ {0, false},
+ {230002, false},
+ {10001, false},
+ }
+ for _, tc := range cases {
+ if got := isTokenError(tc.code); got != tc.want {
+ t.Errorf("isTokenError(%d) = %v, want %v", tc.code, got, tc.want)
+ }
+ }
+}
+
+// --- doJSON token refresh ---
+
+func TestDoJSON_TokenRefreshOnExpiry(t *testing.T) {
+ tokenCalls := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ tokenCalls++
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"fresh-tok","expire":7200}`)
+ return
+ }
+ // First real call returns token error code → triggers refresh
+ w.Header().Set("Content-Type", "application/json")
+ if tokenCalls == 1 {
+ io.WriteString(w, `{"code":99991663,"msg":"token expired","data":{}}`)
+ } else {
+ io.WriteString(w, `{"code":0,"msg":"ok","data":{}}`)
+ }
+ }))
+ defer srv.Close()
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ resp, err := c.doJSON(context.Background(), "GET", "/some/api", nil)
+ if err != nil {
+ t.Fatalf("doJSON returned error: %v", err)
+ }
+ if resp.Code != 0 {
+ t.Errorf("final response code: got %d, want 0", resp.Code)
+ }
+ if tokenCalls < 2 {
+ t.Errorf("expected at least 2 token calls (initial + refresh), got %d", tokenCalls)
+ }
+}
+
+// --- doDownload ---
+
+func TestDoDownload_Success(t *testing.T) {
+ fileBytes := []byte("fake-image-data")
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ // Verify auth header is set
+ if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
+ http.Error(w, "missing auth", http.StatusUnauthorized)
+ return
+ }
+ w.Header().Set("Content-Disposition", `attachment; filename="photo.jpg"`)
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Write(fileBytes)
+ }))
+ defer srv.Close()
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ data, fileName, err := c.doDownload(context.Background(), "/download/img_123")
+ if err != nil {
+ t.Fatalf("doDownload error: %v", err)
+ }
+ if !bytes.Equal(data, fileBytes) {
+ t.Errorf("data mismatch: got %q, want %q", data, fileBytes)
+ }
+ if fileName != "photo.jpg" {
+ t.Errorf("fileName: got %q, want %q", fileName, "photo.jpg")
+ }
+}
+
+func TestDoDownload_JSONErrorResponse(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ // Server responds with JSON error instead of binary
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"code":1234567,"msg":"resource not found","data":{}}`)
+ }))
+ defer srv.Close()
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ _, _, err := c.doDownload(context.Background(), "/download/missing")
+ if err == nil {
+ t.Fatal("expected error for JSON error response")
+ }
+}
+
+func TestDoDownload_NoContentDisposition(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ w.Header().Set("Content-Type", "image/png")
+ w.Write([]byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes
+ }))
+ defer srv.Close()
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ data, fileName, err := c.doDownload(context.Background(), "/download/no_cd")
+ if err != nil {
+ t.Fatalf("doDownload error: %v", err)
+ }
+ if fileName != "" {
+ t.Errorf("fileName: got %q, want empty when no Content-Disposition", fileName)
+ }
+ if len(data) == 0 {
+ t.Error("data should not be empty")
+ }
+}
+
+// --- doMultipart ---
+
+func TestDoMultipart_Success(t *testing.T) {
+ var gotContentType string
+ var gotAuthHeader string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ gotContentType = r.Header.Get("Content-Type")
+ gotAuthHeader = r.Header.Get("Authorization")
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"code":0,"msg":"ok","data":{"image_key":"img_uploaded_123"}}`)
+ }))
+ defer srv.Close()
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ resp, err := c.doMultipart(
+ context.Background(),
+ "/upload/image",
+ map[string]string{"image_type": "message"},
+ "image",
+ bytes.NewReader([]byte{0xFF, 0xD8, 0xFF}), // fake JPEG
+ "test.jpg",
+ )
+ if err != nil {
+ t.Fatalf("doMultipart error: %v", err)
+ }
+ if resp.Code != 0 {
+ t.Errorf("response code: got %d, want 0", resp.Code)
+ }
+ if !strings.HasPrefix(gotContentType, "multipart/form-data") {
+ t.Errorf("content-type: got %q, want multipart/form-data", gotContentType)
+ }
+ if !strings.HasPrefix(gotAuthHeader, "Bearer tok") {
+ t.Errorf("auth header: got %q", gotAuthHeader)
+ }
+}
+
+func TestDoMultipart_NoFile(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"code":0,"msg":"ok","data":{}}`)
+ }))
+ defer srv.Close()
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ // fileField="" and fileData=nil → fields-only upload
+ resp, err := c.doMultipart(
+ context.Background(),
+ "/upload/fields-only",
+ map[string]string{"key": "value"},
+ "",
+ nil,
+ "",
+ )
+ if err != nil {
+ t.Fatalf("doMultipart (no file) error: %v", err)
+ }
+ if resp.Code != 0 {
+ t.Errorf("response code: got %d, want 0", resp.Code)
+ }
+}
+
+func TestDoMultipart_NetworkError(t *testing.T) {
+ c := NewLarkClient("app", "secret", "http://localhost:1")
+ _, err := c.doMultipart(
+ context.Background(),
+ "/upload",
+ nil, "", nil, "",
+ )
+ if err == nil {
+ t.Fatal("expected error for unreachable server")
+ }
+}
diff --git a/internal/channels/feishu/larkclient_messaging_extra_test.go b/internal/channels/feishu/larkclient_messaging_extra_test.go
new file mode 100644
index 00000000..2e2771a6
--- /dev/null
+++ b/internal/channels/feishu/larkclient_messaging_extra_test.go
@@ -0,0 +1,377 @@
+package feishu
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// newSimpleMockServer returns a test server that always responds with the given
+// JSON payload for any non-token request.
+func newSimpleMockServer(t *testing.T, respJSON string) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, respJSON)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// newBinaryMockServer returns a server that serves binary data for any non-token request.
+func newBinaryMockServer(t *testing.T, data []byte, contentType string) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ w.Header().Set("Content-Type", contentType)
+ w.Write(data)
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+// --- DownloadImage ---
+
+func TestDownloadImage_Success(t *testing.T) {
+ imgData := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic
+ srv := newBinaryMockServer(t, imgData, "image/png")
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ data, err := c.DownloadImage(context.Background(), "img_key_123")
+ if err != nil {
+ t.Fatalf("DownloadImage error: %v", err)
+ }
+ if !bytes.Equal(data, imgData) {
+ t.Errorf("data mismatch: got %v, want %v", data, imgData)
+ }
+}
+
+// --- UploadImage ---
+
+func TestUploadImage_Success(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"image_key":"img_uploaded_abc"}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ key, err := c.UploadImage(context.Background(), bytes.NewReader([]byte{0xFF, 0xD8, 0xFF}))
+ if err != nil {
+ t.Fatalf("UploadImage error: %v", err)
+ }
+ if key != "img_uploaded_abc" {
+ t.Errorf("image_key: got %q, want %q", key, "img_uploaded_abc")
+ }
+}
+
+func TestUploadImage_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"quota exceeded","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ _, err := c.UploadImage(context.Background(), bytes.NewReader([]byte{0x01}))
+ if err == nil {
+ t.Fatal("expected error on non-zero code")
+ }
+}
+
+// --- UploadFile ---
+
+func TestUploadFile_Success(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"file_key":"file_key_xyz"}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ key, err := c.UploadFile(context.Background(), bytes.NewReader([]byte("PDF data")), "test.pdf", "pdf", 0)
+ if err != nil {
+ t.Fatalf("UploadFile error: %v", err)
+ }
+ if key != "file_key_xyz" {
+ t.Errorf("file_key: got %q, want %q", key, "file_key_xyz")
+ }
+}
+
+func TestUploadFile_WithDuration(t *testing.T) {
+ var gotBody string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ body, _ := io.ReadAll(r.Body)
+ gotBody = string(body)
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"code":0,"msg":"ok","data":{"file_key":"audio_key"}}`)
+ }))
+ defer srv.Close()
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ _, err := c.UploadFile(context.Background(), bytes.NewReader([]byte("audio")), "voice.opus", "opus", 5000)
+ if err != nil {
+ t.Fatalf("UploadFile error: %v", err)
+ }
+ if !strings.Contains(gotBody, "5000") {
+ t.Errorf("expected duration in body: %s", gotBody)
+ }
+}
+
+// --- DownloadMessageResource ---
+
+func TestDownloadMessageResource_Success(t *testing.T) {
+ fileData := []byte("binary file content")
+ srv := newBinaryMockServer(t, fileData, "application/octet-stream")
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ data, _, err := c.DownloadMessageResource(context.Background(), "om_msg1", "fk_123", "file")
+ if err != nil {
+ t.Fatalf("DownloadMessageResource error: %v", err)
+ }
+ if !bytes.Equal(data, fileData) {
+ t.Errorf("data mismatch")
+ }
+}
+
+// --- CreateCard ---
+
+func TestCreateCard_Success(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"card_id":"card_abc"}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ cardID, err := c.CreateCard(context.Background(), "card", `{"schema":"2.0"}`)
+ if err != nil {
+ t.Fatalf("CreateCard error: %v", err)
+ }
+ if cardID != "card_abc" {
+ t.Errorf("card_id: got %q, want card_abc", cardID)
+ }
+}
+
+func TestCreateCard_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":99001,"msg":"invalid card","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ _, err := c.CreateCard(context.Background(), "card", `{}`)
+ if err == nil {
+ t.Fatal("expected error for non-zero code")
+ }
+}
+
+// --- UpdateCardSettings ---
+
+func TestUpdateCardSettings_Success(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ err := c.UpdateCardSettings(context.Background(), "card_abc", `{"streaming":false}`, 1, "uuid-001")
+ if err != nil {
+ t.Fatalf("UpdateCardSettings error: %v", err)
+ }
+}
+
+func TestUpdateCardSettings_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":500,"msg":"server error","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ err := c.UpdateCardSettings(context.Background(), "card_bad", `{}`, 0, "")
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+// --- UpdateCardElement ---
+
+func TestUpdateCardElement_Success(t *testing.T) {
+ var gotPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ gotPath = r.URL.Path
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"code":0,"msg":"ok","data":{}}`)
+ }))
+ defer srv.Close()
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ err := c.UpdateCardElement(context.Background(), "card_xyz", "elem_1", "new content", 2, "uuid-002")
+ if err != nil {
+ t.Fatalf("UpdateCardElement error: %v", err)
+ }
+ if !strings.Contains(gotPath, "card_xyz") {
+ t.Errorf("path should contain card_id: %q", gotPath)
+ }
+ if !strings.Contains(gotPath, "elem_1") {
+ t.Errorf("path should contain element_id: %q", gotPath)
+ }
+}
+
+func TestUpdateCardElement_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":404,"msg":"element not found","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ err := c.UpdateCardElement(context.Background(), "card_x", "elem_x", "text", 0, "")
+ if err == nil {
+ t.Fatal("expected error for non-zero code")
+ }
+}
+
+// --- AddMessageReaction ---
+
+func TestAddMessageReaction_Success(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"reaction_id":"rxn_abc"}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ reactionID, err := c.AddMessageReaction(context.Background(), "om_msg1", "Typing")
+ if err != nil {
+ t.Fatalf("AddMessageReaction error: %v", err)
+ }
+ if reactionID != "rxn_abc" {
+ t.Errorf("reaction_id: got %q, want rxn_abc", reactionID)
+ }
+}
+
+func TestAddMessageReaction_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":1234,"msg":"reaction failed","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ _, err := c.AddMessageReaction(context.Background(), "om_msg1", "Typing")
+ if err == nil {
+ t.Fatal("expected error for non-zero code")
+ }
+}
+
+// --- DeleteMessageReaction ---
+
+func TestDeleteMessageReaction_Success(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ err := c.DeleteMessageReaction(context.Background(), "om_msg1", "rxn_abc")
+ if err != nil {
+ t.Fatalf("DeleteMessageReaction error: %v", err)
+ }
+}
+
+func TestDeleteMessageReaction_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":404,"msg":"not found","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ err := c.DeleteMessageReaction(context.Background(), "om_msg1", "rxn_missing")
+ if err == nil {
+ t.Fatal("expected error for non-zero code")
+ }
+}
+
+// --- GetBotInfo (returns open_id string) ---
+
+func TestGetBotInfo_Success(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"bot":{"open_id":"ou_bot_123","app_name":"TestBot"}}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ openID, err := c.GetBotInfo(context.Background())
+ if err != nil {
+ t.Fatalf("GetBotInfo error: %v", err)
+ }
+ if openID != "ou_bot_123" {
+ t.Errorf("open_id: got %q, want ou_bot_123", openID)
+ }
+}
+
+func TestGetBotInfo_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"forbidden","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ _, err := c.GetBotInfo(context.Background())
+ if err == nil {
+ t.Fatal("expected error for non-zero code")
+ }
+}
+
+// --- ListChatMembers ---
+
+func TestListChatMembers_Success(t *testing.T) {
+ resp := map[string]any{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]any{
+ "items": []any{
+ map[string]any{"member_id": "ou_A", "name": "Alice"},
+ map[string]any{"member_id": "ou_B", "name": "Bob"},
+ },
+ "has_more": false,
+ },
+ }
+ respJSON, _ := json.Marshal(resp)
+ srv := newSimpleMockServer(t, string(respJSON))
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ members, err := c.ListChatMembers(context.Background(), "oc_chat_1")
+ if err != nil {
+ t.Fatalf("ListChatMembers error: %v", err)
+ }
+ if len(members) != 2 {
+ t.Errorf("expected 2 members, got %d", len(members))
+ }
+ if members[0].MemberID != "ou_A" {
+ t.Errorf("first member: got %q, want ou_A", members[0].MemberID)
+ }
+}
+
+func TestListChatMembers_Pagination(t *testing.T) {
+ // First page: has_more=true, second page: has_more=false
+ callCount := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == tokenEndpoint {
+ io.WriteString(w, `{"code":0,"msg":"ok","tenant_access_token":"tok","expire":7200}`)
+ return
+ }
+ callCount++
+ w.Header().Set("Content-Type", "application/json")
+ if callCount == 1 {
+ json.NewEncoder(w).Encode(map[string]any{
+ "code": 0, "msg": "ok",
+ "data": map[string]any{
+ "items": []any{map[string]any{"member_id": "ou_1", "name": "User1"}},
+ "page_token": "next_page_token",
+ "has_more": true,
+ },
+ })
+ } else {
+ json.NewEncoder(w).Encode(map[string]any{
+ "code": 0, "msg": "ok",
+ "data": map[string]any{
+ "items": []any{map[string]any{"member_id": "ou_2", "name": "User2"}},
+ "has_more": false,
+ },
+ })
+ }
+ }))
+ defer srv.Close()
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ members, err := c.ListChatMembers(context.Background(), "oc_chat_paged")
+ if err != nil {
+ t.Fatalf("ListChatMembers pagination error: %v", err)
+ }
+ if len(members) != 2 {
+ t.Errorf("expected 2 total members from 2 pages, got %d", len(members))
+ }
+}
+
+func TestListChatMembers_APIError(t *testing.T) {
+ srv := newSimpleMockServer(t, `{"code":10001,"msg":"not found","data":{}}`)
+
+ c := NewLarkClient("app", "secret", srv.URL)
+ _, err := c.ListChatMembers(context.Background(), "oc_missing")
+ if err == nil {
+ t.Fatal("expected error for non-zero code")
+ }
+}
diff --git a/internal/channels/feishu/larkevents_test.go b/internal/channels/feishu/larkevents_test.go
new file mode 100644
index 00000000..52dc144c
--- /dev/null
+++ b/internal/channels/feishu/larkevents_test.go
@@ -0,0 +1,341 @@
+package feishu
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// --- helpers ---
+
+// encryptEventPayload encrypts a JSON payload using the same AES-CBC scheme
+// that Feishu uses for encrypted webhook events. Used to test decryptEvent.
+func encryptEventPayload(t *testing.T, payload []byte, key string) string {
+ t.Helper()
+ keyHash := sha256.Sum256([]byte(key))
+ block, err := aes.NewCipher(keyHash[:])
+ if err != nil {
+ t.Fatalf("aes new cipher: %v", err)
+ }
+ // IV = first 16 bytes of key hash (deterministic for testing)
+ iv := keyHash[:aes.BlockSize]
+
+ // PKCS7-pad to block size (16)
+ pad := aes.BlockSize - (len(payload) % aes.BlockSize)
+ padded := make([]byte, len(payload)+pad)
+ copy(padded, payload)
+ for i := len(payload); i < len(padded); i++ {
+ padded[i] = byte(pad)
+ }
+
+ ct := make([]byte, aes.BlockSize+len(padded))
+ copy(ct[:aes.BlockSize], iv)
+ mode := cipher.NewCBCEncrypter(block, iv)
+ mode.CryptBlocks(ct[aes.BlockSize:], padded)
+
+ return base64.StdEncoding.EncodeToString(ct)
+}
+
+// buildWebhookRequest creates a POST request body for a webhook event.
+func buildWebhookRequest(body string) *http.Request {
+ req := httptest.NewRequest(http.MethodPost, "/feishu/events", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ return req
+}
+
+// --- URL verification ---
+
+func TestWebhookHandler_URLVerification(t *testing.T) {
+ called := false
+ h := NewWebhookHandler("", "", func(_ *MessageEvent) { called = true })
+
+ body := `{"type":"url_verification","token":"test-tok","challenge":"abc123"}`
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, buildWebhookRequest(body))
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status: got %d, want 200", w.Code)
+ }
+ var resp map[string]string
+ if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if resp["challenge"] != "abc123" {
+ t.Errorf("challenge: got %q, want %q", resp["challenge"], "abc123")
+ }
+ if called {
+ t.Error("onMessage must not be called for url_verification")
+ }
+}
+
+// --- Method not allowed ---
+
+func TestWebhookHandler_MethodNotAllowed(t *testing.T) {
+ h := NewWebhookHandler("", "", func(_ *MessageEvent) {})
+ req := httptest.NewRequest(http.MethodGet, "/feishu/events", nil)
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, req)
+
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("status: got %d, want 405", w.Code)
+ }
+}
+
+// --- Token verification ---
+
+func TestWebhookHandler_TokenMismatch(t *testing.T) {
+ called := false
+ h := NewWebhookHandler("expected-token", "", func(_ *MessageEvent) { called = true })
+
+ // Build a message event with a wrong token
+ env := map[string]any{
+ "schema": "2.0",
+ "header": map[string]any{
+ "event_type": "im.message.receive_v1",
+ "token": "wrong-token",
+ "app_id": "cli_test",
+ "tenant_key": "test-tenant-1",
+ },
+ "event": map[string]any{
+ "sender": map[string]any{},
+ "message": map[string]any{"message_id": "om_1", "chat_id": "oc_1"},
+ },
+ }
+ body, _ := json.Marshal(env)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, buildWebhookRequest(string(body)))
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status: got %d, want 200", w.Code)
+ }
+ // Token mismatch: handler silently drops, must NOT dispatch
+ if called {
+ t.Error("onMessage must not be called on token mismatch")
+ }
+}
+
+func TestWebhookHandler_TokenMatch_Dispatches(t *testing.T) {
+ dispatched := make(chan *MessageEvent, 1)
+ h := NewWebhookHandler("good-token", "", func(e *MessageEvent) { dispatched <- e })
+
+ env := map[string]any{
+ "schema": "2.0",
+ "header": map[string]any{
+ "event_id": "evt_001",
+ "event_type": "im.message.receive_v1",
+ "token": "good-token",
+ "app_id": "cli_test",
+ "tenant_key": "test-tenant-1",
+ },
+ "event": map[string]any{
+ "sender": map[string]any{
+ "sender_id": map[string]any{"open_id": "ou_U00000001"},
+ "sender_type": "user",
+ "tenant_key": "test-tenant-1",
+ },
+ "message": map[string]any{
+ "message_id": "om_msg001",
+ "chat_id": "oc_chat_001",
+ "chat_type": "p2p",
+ "message_type": "text",
+ "content": `{"text":"hello bot"}`,
+ },
+ },
+ }
+ body, _ := json.Marshal(env)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, buildWebhookRequest(string(body)))
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status: got %d, want 200", w.Code)
+ }
+
+ // Wait for goroutine to dispatch the event (race-free via channel).
+ select {
+ case e := <-dispatched:
+ if e == nil {
+ t.Error("dispatched event was nil")
+ }
+ case <-t.Context().Done():
+ t.Error("timeout waiting for dispatched event")
+ }
+}
+
+// --- Non-message event type ---
+
+func TestWebhookHandler_NonMessageEvent_Ignored(t *testing.T) {
+ called := false
+ h := NewWebhookHandler("", "", func(_ *MessageEvent) { called = true })
+
+ env := map[string]any{
+ "schema": "2.0",
+ "header": map[string]any{
+ "event_type": "im.chat.member.bot.added_v1",
+ "token": "",
+ },
+ }
+ body, _ := json.Marshal(env)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, buildWebhookRequest(string(body)))
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status: got %d, want 200", w.Code)
+ }
+ if called {
+ t.Error("onMessage must not be called for non-message events")
+ }
+}
+
+// --- Invalid JSON ---
+
+func TestWebhookHandler_InvalidJSON(t *testing.T) {
+ h := NewWebhookHandler("", "", func(_ *MessageEvent) {})
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, buildWebhookRequest("not-json{{{"))
+
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status: got %d, want 400", w.Code)
+ }
+}
+
+// --- Encrypted event ---
+
+func TestWebhookHandler_EncryptedEvent_Decrypted(t *testing.T) {
+ const encKey = "test-encrypt-key-2024"
+ dispatched := make(chan *MessageEvent, 1)
+
+ h := NewWebhookHandler("", encKey, func(e *MessageEvent) { dispatched <- e })
+
+ innerEvent := map[string]any{
+ "schema": "2.0",
+ "header": map[string]any{
+ "event_id": "evt_enc_01",
+ "event_type": "im.message.receive_v1",
+ "token": "",
+ "app_id": "cli_enc",
+ "tenant_key": "test-tenant-1",
+ },
+ "event": map[string]any{
+ "sender": map[string]any{
+ "sender_id": map[string]any{"open_id": "ou_U00000002"},
+ "tenant_key": "test-tenant-1",
+ },
+ "message": map[string]any{
+ "message_id": "om_enc001",
+ "chat_id": "oc_chat_enc",
+ "chat_type": "p2p",
+ "message_type": "text",
+ "content": `{"text":"encrypted message"}`,
+ },
+ },
+ }
+ plainJSON, _ := json.Marshal(innerEvent)
+ encrypted := encryptEventPayload(t, plainJSON, encKey)
+
+ outerBody := map[string]any{"encrypt": encrypted}
+ outerJSON, _ := json.Marshal(outerBody)
+
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, buildWebhookRequest(string(outerJSON)))
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status: got %d, want 200", w.Code)
+ }
+
+ // Wait for goroutine to dispatch — race-free via buffered channel.
+ select {
+ case e := <-dispatched:
+ if e == nil {
+ t.Error("dispatched event was nil")
+ }
+ case <-t.Context().Done():
+ t.Error("timeout waiting for dispatched encrypted event")
+ }
+}
+
+// --- decryptEvent unit tests ---
+
+func TestDecryptEvent_InvalidBase64(t *testing.T) {
+ _, err := decryptEvent("not-valid-base64!!!", "key")
+ if err == nil {
+ t.Fatal("expected error for invalid base64")
+ }
+}
+
+func TestDecryptEvent_TooShort(t *testing.T) {
+ // Valid base64 but shorter than AES block size (16 bytes)
+ short := base64.StdEncoding.EncodeToString([]byte("short"))
+ _, err := decryptEvent(short, "key")
+ if err == nil {
+ t.Fatal("expected error for ciphertext too short")
+ }
+}
+
+func TestDecryptEvent_NoJSONInPlaintext(t *testing.T) {
+ // Encrypt something that has no { } after decryption.
+ // Use all-zero padding — won't contain valid JSON braces in useful positions.
+ keyHash := sha256.Sum256([]byte("testkey"))
+ block, _ := aes.NewCipher(keyHash[:])
+ iv := keyHash[:aes.BlockSize]
+ // Plaintext: 16 bytes of 0x10 (valid PKCS7 padding for 16-byte block)
+ pt := make([]byte, 16)
+ for i := range pt {
+ pt[i] = 0x10
+ }
+ ct := make([]byte, aes.BlockSize+16)
+ copy(ct[:aes.BlockSize], iv)
+ mode := cipher.NewCBCEncrypter(block, iv)
+ mode.CryptBlocks(ct[aes.BlockSize:], pt)
+ encoded := base64.StdEncoding.EncodeToString(ct)
+
+ _, err := decryptEvent(encoded, "testkey")
+ if err == nil {
+ t.Fatal("expected error when no JSON found in decrypted content")
+ }
+}
+
+// TestDecryptEvent_TamperedPayload is the security-critical test.
+// AES-CBC alone does not provide authentication (no HMAC/GCM), so a wrong key
+// will still "decrypt" the bytes — but the result will be garbled, non-parseable
+// JSON. The webhook handler re-parses the decrypted body with json.Unmarshal and
+// then checks the verification token; a tampered payload therefore cannot impersonate
+// a valid event.
+//
+// This test asserts the observable contract: when decryptEvent is called with the
+// correct key it returns the original plaintext; when called with a wrong key the
+// returned bytes do NOT equal the original plaintext (the decryption is corrupted).
+func TestDecryptEvent_TamperedPayload(t *testing.T) {
+ const realKey = "real-encrypt-key-abc"
+ const wrongKey = "wrong-key-tampered"
+
+ plaintext := []byte(`{"type":"url_verification","challenge":"xyz"}`)
+ encrypted := encryptEventPayload(t, plaintext, realKey)
+
+ // With the correct key the original JSON is recovered.
+ gotReal, err := decryptEvent(encrypted, realKey)
+ if err != nil {
+ t.Fatalf("decryptEvent with real key failed: %v", err)
+ }
+ if string(gotReal) != string(plaintext) {
+ t.Errorf("real-key decrypt: got %q, want %q", gotReal, plaintext)
+ }
+
+ // With a wrong key: AES-CBC produces garbled output.
+ // The function may or may not error (depending on whether { } appear in garbage),
+ // but the result MUST differ from the original plaintext — the tampered payload
+ // cannot reconstruct the valid event content.
+ gotWrong, errWrong := decryptEvent(encrypted, wrongKey)
+ if errWrong == nil && string(gotWrong) == string(plaintext) {
+ t.Error("SECURITY: decryptEvent with wrong key returned the original plaintext — tampered payload was not corrupted")
+ }
+ // If errWrong != nil, the garbage didn't even contain { } — that's an even stronger failure.
+}
diff --git a/internal/channels/feishu/larkws_proto_test.go b/internal/channels/feishu/larkws_proto_test.go
new file mode 100644
index 00000000..34ad0268
--- /dev/null
+++ b/internal/channels/feishu/larkws_proto_test.go
@@ -0,0 +1,170 @@
+package feishu
+
+import (
+ "bytes"
+ "testing"
+)
+
+// TestMarshalUnmarshalRoundTrip verifies that a wsFrame survives a
+// marshal → unmarshal round-trip with no data loss.
+func TestMarshalUnmarshalRoundTrip(t *testing.T) {
+ original := &wsFrame{
+ SeqID: 42,
+ LogID: 99,
+ Service: 1,
+ Method: 2,
+ PayloadEncoding: "utf-8",
+ PayloadType: "json",
+ Payload: []byte(`{"hello":"world"}`),
+ LogIDNew: "logid-abc",
+ Headers: []wsHeader{
+ {Key: "type", Value: "event"},
+ {Key: "message_id", Value: "msg_001"},
+ },
+ }
+
+ data := marshalFrame(original)
+ if len(data) == 0 {
+ t.Fatal("marshalFrame returned empty bytes")
+ }
+
+ got, err := unmarshalFrame(data)
+ if err != nil {
+ t.Fatalf("unmarshalFrame returned error: %v", err)
+ }
+
+ if got.SeqID != original.SeqID {
+ t.Errorf("SeqID: got %d, want %d", got.SeqID, original.SeqID)
+ }
+ if got.LogID != original.LogID {
+ t.Errorf("LogID: got %d, want %d", got.LogID, original.LogID)
+ }
+ if got.Service != original.Service {
+ t.Errorf("Service: got %d, want %d", got.Service, original.Service)
+ }
+ if got.Method != original.Method {
+ t.Errorf("Method: got %d, want %d", got.Method, original.Method)
+ }
+ if got.PayloadEncoding != original.PayloadEncoding {
+ t.Errorf("PayloadEncoding: got %q, want %q", got.PayloadEncoding, original.PayloadEncoding)
+ }
+ if got.PayloadType != original.PayloadType {
+ t.Errorf("PayloadType: got %q, want %q", got.PayloadType, original.PayloadType)
+ }
+ if !bytes.Equal(got.Payload, original.Payload) {
+ t.Errorf("Payload: got %q, want %q", got.Payload, original.Payload)
+ }
+ if got.LogIDNew != original.LogIDNew {
+ t.Errorf("LogIDNew: got %q, want %q", got.LogIDNew, original.LogIDNew)
+ }
+ if len(got.Headers) != len(original.Headers) {
+ t.Fatalf("Headers len: got %d, want %d", len(got.Headers), len(original.Headers))
+ }
+ hm := got.headerMap()
+ if hm["type"] != "event" {
+ t.Errorf("header type: got %q, want %q", hm["type"], "event")
+ }
+ if hm["message_id"] != "msg_001" {
+ t.Errorf("header message_id: got %q, want %q", hm["message_id"], "msg_001")
+ }
+}
+
+// TestMarshalUnmarshal_ZeroValues verifies frames with all-zero/empty fields.
+func TestMarshalUnmarshal_ZeroValues(t *testing.T) {
+ f := &wsFrame{}
+ data := marshalFrame(f)
+ got, err := unmarshalFrame(data)
+ if err != nil {
+ t.Fatalf("unmarshalFrame zero frame error: %v", err)
+ }
+ if got.SeqID != 0 || got.LogID != 0 {
+ t.Errorf("expected zero IDs, got SeqID=%d LogID=%d", got.SeqID, got.LogID)
+ }
+}
+
+// TestMarshalUnmarshal_OnlyPayload verifies payload-only frames work.
+func TestMarshalUnmarshal_OnlyPayload(t *testing.T) {
+ payload := []byte("raw-binary-data")
+ f := &wsFrame{Payload: payload}
+ data := marshalFrame(f)
+ got, err := unmarshalFrame(data)
+ if err != nil {
+ t.Fatalf("error: %v", err)
+ }
+ if !bytes.Equal(got.Payload, payload) {
+ t.Errorf("payload mismatch: got %q, want %q", got.Payload, payload)
+ }
+}
+
+// TestUnmarshalFrame_MalformedInput verifies no panic and an error is returned.
+func TestUnmarshalFrame_MalformedInput(t *testing.T) {
+ cases := []struct {
+ name string
+ data []byte
+ }{
+ {"empty", []byte{}},
+ {"truncated varint", []byte{0x80, 0x80}}, // incomplete multi-byte varint
+ {"bad wire type", []byte{0x0f}}, // field 1, wire type 7 (unsupported)
+ {"length prefix no data", []byte{0x12, 0x05}}, // field 2 wire 2, length=5 but no data
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Must not panic.
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("unmarshalFrame panicked: %v", r)
+ }
+ }()
+ // Error or not — just must not panic. Empty input is valid and returns empty frame.
+ _, _ = unmarshalFrame(tc.data)
+ })
+ }
+}
+
+// TestHeaderMap_Empty verifies headerMap on a frame with no headers.
+func TestHeaderMap_Empty(t *testing.T) {
+ f := &wsFrame{}
+ m := f.headerMap()
+ if len(m) != 0 {
+ t.Errorf("expected empty map, got %v", m)
+ }
+}
+
+// TestHeaderMap_Dedup verifies last-writer-wins for duplicate header keys.
+func TestHeaderMap_Dedup(t *testing.T) {
+ f := &wsFrame{Headers: []wsHeader{
+ {Key: "type", Value: "first"},
+ {Key: "type", Value: "second"},
+ }}
+ m := f.headerMap()
+ if m["type"] != "second" {
+ t.Errorf("expected 'second', got %q", m["type"])
+ }
+}
+
+// TestMarshalFrame_MultipleHeaders verifies multiple distinct headers survive.
+func TestMarshalFrame_MultipleHeaders(t *testing.T) {
+ f := &wsFrame{
+ Headers: []wsHeader{
+ {Key: "sum", Value: "3"},
+ {Key: "seq", Value: "1"},
+ {Key: "message_id", Value: "fragmented-msg"},
+ },
+ }
+ data := marshalFrame(f)
+ got, err := unmarshalFrame(data)
+ if err != nil {
+ t.Fatalf("error: %v", err)
+ }
+ hm := got.headerMap()
+ if hm["sum"] != "3" {
+ t.Errorf("sum: got %q", hm["sum"])
+ }
+ if hm["seq"] != "1" {
+ t.Errorf("seq: got %q", hm["seq"])
+ }
+ if hm["message_id"] != "fragmented-msg" {
+ t.Errorf("message_id: got %q", hm["message_id"])
+ }
+}
diff --git a/internal/channels/feishu/larkws_test.go b/internal/channels/feishu/larkws_test.go
new file mode 100644
index 00000000..cc692959
--- /dev/null
+++ b/internal/channels/feishu/larkws_test.go
@@ -0,0 +1,348 @@
+package feishu
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+// --- Fragment reassembly ---
+
+func TestReassemble_SingleFragment(t *testing.T) {
+ c := NewWSClient("app", "secret", "http://localhost", nil)
+ // sum=1 means no fragmentation needed — caller skips reassemble
+ // but if called with sum=1 it should complete immediately
+ result := c.reassemble("msg_single", 1, 0, []byte("payload"))
+ if result == nil {
+ t.Error("single fragment should return payload immediately")
+ }
+ if string(result) != "payload" {
+ t.Errorf("got %q, want %q", result, "payload")
+ }
+}
+
+func TestReassemble_TwoFragments_OutOfOrder(t *testing.T) {
+ c := NewWSClient("app", "secret", "http://localhost", nil)
+
+ // Arrive out of order: seq=1 before seq=0
+ result1 := c.reassemble("msg_two", 2, 1, []byte("world"))
+ if result1 != nil {
+ t.Error("first fragment should not complete reassembly")
+ }
+
+ result2 := c.reassemble("msg_two", 2, 0, []byte("hello"))
+ if result2 == nil {
+ t.Fatal("second fragment should complete reassembly")
+ }
+ // Assembled in seq order: seq0 + seq1
+ if string(result2) != "helloworld" {
+ t.Errorf("reassembly: got %q, want %q", result2, "helloworld")
+ }
+}
+
+func TestReassemble_ThreeFragments(t *testing.T) {
+ c := NewWSClient("app", "secret", "http://localhost", nil)
+
+ c.reassemble("msg_three", 3, 0, []byte("aaa"))
+ c.reassemble("msg_three", 3, 2, []byte("ccc"))
+ result := c.reassemble("msg_three", 3, 1, []byte("bbb"))
+
+ if result == nil {
+ t.Fatal("all 3 fragments received, should complete")
+ }
+ if string(result) != "aaabbbccc" {
+ t.Errorf("got %q, want %q", result, "aaabbbccc")
+ }
+}
+
+func TestReassemble_DifferentMessages_Isolated(t *testing.T) {
+ c := NewWSClient("app", "secret", "http://localhost", nil)
+
+ // Two separate messages, each with 2 parts
+ c.reassemble("msgA", 2, 0, []byte("A0"))
+ c.reassemble("msgB", 2, 0, []byte("B0"))
+
+ resultA := c.reassemble("msgA", 2, 1, []byte("A1"))
+ resultB := c.reassemble("msgB", 2, 1, []byte("B1"))
+
+ if resultA == nil || string(resultA) != "A0A1" {
+ t.Errorf("msgA: got %q, want A0A1", resultA)
+ }
+ if resultB == nil || string(resultB) != "B0B1" {
+ t.Errorf("msgB: got %q, want B0B1", resultB)
+ }
+}
+
+func TestReassemble_BufferCleanedAfterComplete(t *testing.T) {
+ c := NewWSClient("app", "secret", "http://localhost", nil)
+
+ c.reassemble("msg_clean", 2, 0, []byte("x"))
+ c.reassemble("msg_clean", 2, 1, []byte("y"))
+
+ // After completion the key is deleted; sending the same msgID again
+ // starts a fresh buffer.
+ result := c.reassemble("msg_clean", 2, 0, []byte("fresh"))
+ if result != nil {
+ t.Error("fresh start with 2 frags: first frag should not complete")
+ }
+}
+
+// --- NewWSClient constructor ---
+
+func TestNewWSClient_Defaults(t *testing.T) {
+ c := NewWSClient("myapp", "mysecret", "https://open.larksuite.com", nil)
+ if c.appID != "myapp" {
+ t.Errorf("appID: got %q", c.appID)
+ }
+ if c.pingInterval != defaultPingInterval {
+ t.Errorf("pingInterval: got %v, want %v", c.pingInterval, defaultPingInterval)
+ }
+ if c.reconnectMax != -1 {
+ t.Errorf("reconnectMax: got %d, want -1 (infinite)", c.reconnectMax)
+ }
+ if c.fragments == nil {
+ t.Error("fragments map must be initialised")
+ }
+}
+
+// --- Stop idempotent ---
+
+func TestWSClient_Stop_Idempotent(t *testing.T) {
+ c := NewWSClient("app", "secret", "http://localhost", nil)
+ c.mu.Lock()
+ c.stopCh = make(chan struct{})
+ c.stopped = false
+ c.mu.Unlock()
+
+ // First Stop should not panic
+ c.Stop()
+ // Second Stop should also not panic
+ c.Stop()
+}
+
+// --- getWSEndpoint via httptest ---
+
+func TestGetWSEndpoint_Success(t *testing.T) {
+ upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
+ var wsURL string
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/callback/ws/endpoint" {
+ // Return a WS URL pointing back to this server
+ wsURL = "ws://" + r.Host + "/ws"
+ resp := map[string]any{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]any{
+ "URL": wsURL,
+ "ClientConfig": map[string]any{
+ "PingInterval": 30,
+ "ReconnectCount": 5,
+ "ReconnectInterval": 3,
+ "ReconnectNonce": 10,
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ return
+ }
+ if r.URL.Path == "/ws" {
+ upgrader.Upgrade(w, r, nil) //nolint
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ defer srv.Close()
+
+ c := NewWSClient("app", "secret", srv.URL, nil)
+ ctx := context.Background()
+ url, err := c.getWSEndpoint(ctx)
+ if err != nil {
+ t.Fatalf("getWSEndpoint returned error: %v", err)
+ }
+ if !strings.HasPrefix(url, "ws://") {
+ t.Errorf("expected ws:// URL, got %q", url)
+ }
+ // Config should be applied
+ if c.pingInterval != 30*time.Second {
+ t.Errorf("pingInterval not applied: got %v", c.pingInterval)
+ }
+ if c.reconnectMax != 5 {
+ t.Errorf("reconnectMax not applied: got %d", c.reconnectMax)
+ }
+}
+
+func TestGetWSEndpoint_APIError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]any{
+ "code": 10001,
+ "msg": "invalid credentials",
+ "data": map[string]any{},
+ })
+ }))
+ defer srv.Close()
+
+ c := NewWSClient("bad-app", "bad-secret", srv.URL, nil)
+ _, err := c.getWSEndpoint(context.Background())
+ if err == nil {
+ t.Fatal("expected error for non-zero code")
+ }
+}
+
+func TestGetWSEndpoint_NetworkError(t *testing.T) {
+ c := NewWSClient("app", "secret", "http://localhost:1", nil)
+ _, err := c.getWSEndpoint(context.Background())
+ if err == nil {
+ t.Fatal("expected error for unreachable server")
+ }
+}
+
+// --- handleFrame: pong updates config ---
+
+func TestHandleFrame_PongUpdatesConfig(t *testing.T) {
+ c := NewWSClient("app", "secret", "http://localhost", nil)
+ c.mu.Lock()
+ c.stopCh = make(chan struct{})
+ c.mu.Unlock()
+
+ pongPayload, _ := json.Marshal(map[string]any{
+ "PingInterval": 60,
+ "ReconnectCount": 3,
+ "ReconnectInterval": 10,
+ "ReconnectNonce": 5,
+ })
+
+ frame := &wsFrame{
+ Method: frameTypeControl,
+ Headers: []wsHeader{{Key: "type", Value: "pong"}},
+ Payload: pongPayload,
+ }
+
+ c.handleFrame(context.Background(), frame)
+
+ if c.pingInterval != 60*time.Second {
+ t.Errorf("pingInterval: got %v, want 60s", c.pingInterval)
+ }
+ if c.reconnectMax != 3 {
+ t.Errorf("reconnectMax: got %d, want 3", c.reconnectMax)
+ }
+ if c.reconnectInterval != 10*time.Second {
+ t.Errorf("reconnectInterval: got %v, want 10s", c.reconnectInterval)
+ }
+ if c.reconnectNonce != 5 {
+ t.Errorf("reconnectNonce: got %d, want 5", c.reconnectNonce)
+ }
+}
+
+// --- handleFrame: data event dispatches to handler ---
+
+type captureHandler struct {
+ payloads [][]byte
+}
+
+func (h *captureHandler) HandleEvent(_ context.Context, payload []byte) error {
+ h.payloads = append(h.payloads, payload)
+ return nil
+}
+
+func TestHandleFrame_DataEvent_Dispatched(t *testing.T) {
+ handler := &captureHandler{}
+ c := NewWSClient("app", "secret", "http://localhost", handler)
+ c.mu.Lock()
+ c.stopCh = make(chan struct{})
+ c.mu.Unlock()
+
+ eventPayload := []byte(`{"schema":"2.0","header":{"event_type":"im.message.receive_v1"}}`)
+ frame := &wsFrame{
+ SeqID: 1,
+ Method: frameTypeData,
+ Headers: []wsHeader{{Key: "type", Value: "event"}},
+ Payload: eventPayload,
+ }
+
+ c.handleFrame(context.Background(), frame)
+
+ if len(handler.payloads) != 1 {
+ t.Fatalf("expected 1 dispatched event, got %d", len(handler.payloads))
+ }
+ if !bytes.Equal(handler.payloads[0], eventPayload) {
+ t.Errorf("payload mismatch: got %q", handler.payloads[0])
+ }
+}
+
+func TestHandleFrame_NonEventDataFrame_Ignored(t *testing.T) {
+ handler := &captureHandler{}
+ c := NewWSClient("app", "secret", "http://localhost", handler)
+ c.mu.Lock()
+ c.stopCh = make(chan struct{})
+ c.mu.Unlock()
+
+ // "card" type should be ignored
+ frame := &wsFrame{
+ Method: frameTypeData,
+ Headers: []wsHeader{{Key: "type", Value: "card"}},
+ Payload: []byte(`{}`),
+ }
+
+ c.handleFrame(context.Background(), frame)
+
+ if len(handler.payloads) != 0 {
+ t.Errorf("non-event frame should not be dispatched, got %d events", len(handler.payloads))
+ }
+}
+
+func TestHandleFrame_DataEvent_Fragmented(t *testing.T) {
+ handler := &captureHandler{}
+ c := NewWSClient("app", "secret", "http://localhost", handler)
+ c.mu.Lock()
+ c.stopCh = make(chan struct{})
+ c.mu.Unlock()
+
+ part1 := []byte(`{"half":`)
+ part2 := []byte(`"data"}`)
+
+ // Send fragment 0 of 2 — should not dispatch
+ frame1 := &wsFrame{
+ Method: frameTypeData,
+ Headers: []wsHeader{
+ {Key: "type", Value: "event"},
+ {Key: "message_id", Value: "frag_evt_1"},
+ {Key: "sum", Value: "2"},
+ {Key: "seq", Value: "0"},
+ },
+ Payload: part1,
+ }
+ c.handleFrame(context.Background(), frame1)
+ if len(handler.payloads) != 0 {
+ t.Error("partial fragment should not dispatch")
+ }
+
+ // Send fragment 1 of 2 — should complete and dispatch
+ frame2 := &wsFrame{
+ Method: frameTypeData,
+ Headers: []wsHeader{
+ {Key: "type", Value: "event"},
+ {Key: "message_id", Value: "frag_evt_1"},
+ {Key: "sum", Value: "2"},
+ {Key: "seq", Value: "1"},
+ },
+ Payload: part2,
+ }
+ c.handleFrame(context.Background(), frame2)
+ if len(handler.payloads) != 1 {
+ t.Fatalf("completed fragments should dispatch once, got %d", len(handler.payloads))
+ }
+ combined := string(handler.payloads[0])
+ if combined != `{"half":"data"}` {
+ t.Errorf("reassembled payload: got %q", combined)
+ }
+}
diff --git a/internal/channels/feishu/media_helpers_test.go b/internal/channels/feishu/media_helpers_test.go
new file mode 100644
index 00000000..c774e8f7
--- /dev/null
+++ b/internal/channels/feishu/media_helpers_test.go
@@ -0,0 +1,69 @@
+package feishu
+
+import (
+ "testing"
+)
+
+// --- isImageContentType ---
+
+func TestIsImageContentType(t *testing.T) {
+ cases := []struct {
+ ct string
+ want bool
+ }{
+ {"image/png", true},
+ {"image/jpeg", true},
+ {"image/gif", true},
+ {"image/webp", true},
+ {"image", true},
+ {"video/mp4", false},
+ {"audio/ogg", false},
+ {"application/pdf", false},
+ {"", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.ct, func(t *testing.T) {
+ got := isImageContentType(tc.ct)
+ if got != tc.want {
+ t.Errorf("isImageContentType(%q) = %v, want %v", tc.ct, got, tc.want)
+ }
+ })
+ }
+}
+
+// --- detectFileType ---
+
+func TestDetectFileType(t *testing.T) {
+ cases := []struct {
+ fileName string
+ want string
+ }{
+ {"voice.opus", "opus"},
+ {"audio.ogg", "opus"},
+ {"video.mp4", "mp4"},
+ {"clip.mov", "mp4"},
+ {"movie.avi", "mp4"},
+ {"screen.wmv", "mp4"},
+ {"film.mkv", "mp4"},
+ {"doc.pdf", "pdf"},
+ {"letter.doc", "doc"},
+ {"report.docx", "doc"},
+ {"data.xls", "xls"},
+ {"data.xlsx", "xls"},
+ {"slides.ppt", "ppt"},
+ {"slides.pptx", "ppt"},
+ {"unknown.zip", "stream"},
+ {"noext", "stream"},
+ {"", "stream"},
+ {"UPPER.PDF", "pdf"}, // case-insensitive via ToLower on ext
+ {"Mixed.Opus", "opus"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.fileName, func(t *testing.T) {
+ got := detectFileType(tc.fileName)
+ if got != tc.want {
+ t.Errorf("detectFileType(%q) = %q, want %q", tc.fileName, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/channels/feishu/media_inbound_test.go b/internal/channels/feishu/media_inbound_test.go
new file mode 100644
index 00000000..b8e34425
--- /dev/null
+++ b/internal/channels/feishu/media_inbound_test.go
@@ -0,0 +1,216 @@
+package feishu
+
+import (
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+)
+
+// --- detectImageFormat ---
+
+func TestDetectImageFormat_JPEG(t *testing.T) {
+ // JPEG magic bytes: FF D8 FF
+ data := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10}
+ ct, ext := detectImageFormat(data)
+ if ct != "image/jpeg" {
+ t.Errorf("ct: got %q, want image/jpeg", ct)
+ }
+ if ext != ".jpg" {
+ t.Errorf("ext: got %q, want .jpg", ext)
+ }
+}
+
+func TestDetectImageFormat_PNG(t *testing.T) {
+ // PNG magic bytes: 89 50 4E 47
+ data := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ ct, ext := detectImageFormat(data)
+ if ct != "image/png" {
+ t.Errorf("ct: got %q, want image/png", ct)
+ }
+ if ext != ".png" {
+ t.Errorf("ext: got %q, want .png", ext)
+ }
+}
+
+func TestDetectImageFormat_GIF(t *testing.T) {
+ // GIF magic bytes: 47 49 46 38
+ data := []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}
+ ct, ext := detectImageFormat(data)
+ if ct != "image/gif" {
+ t.Errorf("ct: got %q, want image/gif", ct)
+ }
+ if ext != ".gif" {
+ t.Errorf("ext: got %q, want .gif", ext)
+ }
+}
+
+func TestDetectImageFormat_WebP(t *testing.T) {
+ // WebP: RIFF....WEBP
+ data := make([]byte, 12)
+ copy(data[0:4], []byte("RIFF"))
+ copy(data[8:12], []byte("WEBP"))
+ ct := http.DetectContentType(data)
+ // http.DetectContentType may not detect WebP as image/webp on all Go versions
+ // so we call our function and just verify it doesn't panic and returns valid values
+ gotCT, gotExt := detectImageFormat(data)
+ if gotCT == "" || gotExt == "" {
+ t.Errorf("detectImageFormat returned empty values: ct=%q ext=%q", gotCT, gotExt)
+ }
+ _ = ct
+}
+
+func TestDetectImageFormat_Unknown(t *testing.T) {
+ // Unknown binary → defaults to image/png
+ data := []byte{0x00, 0x01, 0x02, 0x03, 0x04}
+ ct, ext := detectImageFormat(data)
+ if ct != "image/png" {
+ t.Errorf("ct: got %q, want image/png (default fallback)", ct)
+ }
+ if ext != ".png" {
+ t.Errorf("ext: got %q, want .png (default fallback)", ext)
+ }
+}
+
+func TestDetectImageFormat_EmptyData(t *testing.T) {
+ ct, ext := detectImageFormat(nil)
+ if ct == "" || ext == "" {
+ t.Errorf("detectImageFormat(nil) returned empty: ct=%q ext=%q", ct, ext)
+ }
+}
+
+// --- extractJSONField ---
+
+func TestExtractJSONField_Found(t *testing.T) {
+ json := `{"image_key":"img_abc123","other":"value"}`
+ got := extractJSONField(json, "image_key")
+ if got != "img_abc123" {
+ t.Errorf("got %q, want %q", got, "img_abc123")
+ }
+}
+
+func TestExtractJSONField_FileKey(t *testing.T) {
+ json := `{"file_key":"file_xyz789","file_name":"doc.pdf"}`
+ got := extractJSONField(json, "file_key")
+ if got != "file_xyz789" {
+ t.Errorf("got %q, want %q", got, "file_xyz789")
+ }
+}
+
+func TestExtractJSONField_Missing(t *testing.T) {
+ json := `{"other":"value"}`
+ got := extractJSONField(json, "image_key")
+ if got != "" {
+ t.Errorf("got %q, want empty string", got)
+ }
+}
+
+func TestExtractJSONField_EmptyString(t *testing.T) {
+ got := extractJSONField("", "image_key")
+ if got != "" {
+ t.Errorf("got %q, want empty", got)
+ }
+}
+
+func TestExtractJSONField_UnclosedValue(t *testing.T) {
+ // Missing closing quote → returns empty
+ json := `{"image_key":"unclosed`
+ got := extractJSONField(json, "image_key")
+ if got != "" {
+ t.Errorf("got %q, want empty for unclosed value", got)
+ }
+}
+
+// --- saveMediaToTemp ---
+
+func TestSaveMediaToTemp_Basic(t *testing.T) {
+ data := []byte("test image data")
+ path, err := saveMediaToTemp(data, "img", ".png")
+ if err != nil {
+ t.Fatalf("saveMediaToTemp error: %v", err)
+ }
+ defer os.Remove(path)
+
+ if !strings.Contains(path, "feishu_img_") {
+ t.Errorf("path missing prefix: %q", path)
+ }
+ if !strings.HasSuffix(path, ".png") {
+ t.Errorf("path missing .png suffix: %q", path)
+ }
+
+ // Verify file content
+ content, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read temp file: %v", err)
+ }
+ if string(content) != "test image data" {
+ t.Errorf("content mismatch: got %q", content)
+ }
+}
+
+func TestSaveMediaToTemp_EmptyExt(t *testing.T) {
+ data := []byte("binary")
+ path, err := saveMediaToTemp(data, "file", "")
+ if err != nil {
+ t.Fatalf("saveMediaToTemp error: %v", err)
+ }
+ defer os.Remove(path)
+
+ if !strings.HasSuffix(path, ".bin") {
+ t.Errorf("expected .bin default ext, got %q", path)
+ }
+}
+
+func TestSaveMediaToTemp_AudioExt(t *testing.T) {
+ data := []byte("audio bytes")
+ path, err := saveMediaToTemp(data, "audio", ".opus")
+ if err != nil {
+ t.Fatalf("saveMediaToTemp error: %v", err)
+ }
+ defer os.Remove(path)
+
+ if !strings.HasSuffix(path, ".opus") {
+ t.Errorf("expected .opus, got %q", path)
+ }
+}
+
+// --- mediaMaxBytes ---
+
+func TestMediaMaxBytes_Default(t *testing.T) {
+ ch := &Channel{}
+ maxBytes := ch.mediaMaxBytes()
+ expected := int64(defaultMediaMaxMB) * 1024 * 1024
+ if maxBytes != expected {
+ t.Errorf("mediaMaxBytes: got %d, want %d", maxBytes, expected)
+ }
+}
+
+func TestMediaMaxBytes_Custom(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.MediaMaxMB = 10
+ maxBytes := ch.mediaMaxBytes()
+ expected := int64(10) * 1024 * 1024
+ if maxBytes != expected {
+ t.Errorf("mediaMaxBytes: got %d, want %d", maxBytes, expected)
+ }
+}
+
+func TestMediaMaxBytes_ZeroFallsToDefault(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.MediaMaxMB = 0
+ maxBytes := ch.mediaMaxBytes()
+ expected := int64(defaultMediaMaxMB) * 1024 * 1024
+ if maxBytes != expected {
+ t.Errorf("mediaMaxBytes(0): got %d, want default %d", maxBytes, expected)
+ }
+}
+
+func TestMediaMaxBytes_NegativeFallsToDefault(t *testing.T) {
+ ch := &Channel{}
+ ch.cfg.MediaMaxMB = -1
+ maxBytes := ch.mediaMaxBytes()
+ expected := int64(defaultMediaMaxMB) * 1024 * 1024
+ if maxBytes != expected {
+ t.Errorf("mediaMaxBytes(-1): got %d, want default %d", maxBytes, expected)
+ }
+}
diff --git a/internal/providers/acp/helpers_test.go b/internal/providers/acp/helpers_test.go
new file mode 100644
index 00000000..41d73da8
--- /dev/null
+++ b/internal/providers/acp/helpers_test.go
@@ -0,0 +1,230 @@
+package acp
+
+import (
+ "strings"
+ "sync"
+ "testing"
+)
+
+// --- filterACPEnv tests ---
+
+func TestFilterACPEnv_RemovesSensitivePrefixes(t *testing.T) {
+ cases := []struct {
+ name string
+ env []string
+ removed []string
+ kept []string
+ }{
+ {
+ name: "GOCLAW prefix",
+ env: []string{"GOCLAW_TOKEN=abc", "HOME=/root"},
+ removed: []string{"GOCLAW_TOKEN=abc"},
+ kept: []string{"HOME=/root"},
+ },
+ {
+ name: "ANTHROPIC prefix",
+ env: []string{"ANTHROPIC_API_KEY=sk-ant-123", "PATH=/usr/bin"},
+ removed: []string{"ANTHROPIC_API_KEY=sk-ant-123"},
+ kept: []string{"PATH=/usr/bin"},
+ },
+ {
+ name: "OPENAI prefix",
+ env: []string{"OPENAI_API_KEY=sk-123", "TERM=xterm"},
+ removed: []string{"OPENAI_API_KEY=sk-123"},
+ kept: []string{"TERM=xterm"},
+ },
+ {
+ name: "DATABASE prefix",
+ env: []string{"DATABASE_URL=postgres://host/db", "USER=alice"},
+ removed: []string{"DATABASE_URL=postgres://host/db"},
+ kept: []string{"USER=alice"},
+ },
+ {
+ name: "AWS_ prefix",
+ env: []string{"AWS_ACCESS_KEY_ID=AKIA123", "LANG=en_US.UTF-8"},
+ removed: []string{"AWS_ACCESS_KEY_ID=AKIA123"},
+ kept: []string{"LANG=en_US.UTF-8"},
+ },
+ {
+ name: "GITHUB_ prefix",
+ env: []string{"GITHUB_TOKEN=ghp_xxx", "SHELL=/bin/zsh"},
+ removed: []string{"GITHUB_TOKEN=ghp_xxx"},
+ kept: []string{"SHELL=/bin/zsh"},
+ },
+ {
+ name: "SSH_ prefix",
+ env: []string{"SSH_AUTH_SOCK=/tmp/agent.123", "EDITOR=vim"},
+ removed: []string{"SSH_AUTH_SOCK=/tmp/agent.123"},
+ kept: []string{"EDITOR=vim"},
+ },
+ {
+ name: "STRIPE_ prefix",
+ env: []string{"STRIPE_SECRET_KEY=sk_live_abc", "TZ=UTC"},
+ removed: []string{"STRIPE_SECRET_KEY=sk_live_abc"},
+ kept: []string{"TZ=UTC"},
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ filtered := filterACPEnv(tc.env)
+ filteredSet := make(map[string]bool, len(filtered))
+ for _, e := range filtered {
+ filteredSet[e] = true
+ }
+ for _, r := range tc.removed {
+ if filteredSet[r] {
+ t.Errorf("expected %q to be removed but it was kept", r)
+ }
+ }
+ for _, k := range tc.kept {
+ if !filteredSet[k] {
+ t.Errorf("expected %q to be kept but it was removed", k)
+ }
+ }
+ })
+ }
+}
+
+func TestFilterACPEnv_RemovesExactNames(t *testing.T) {
+ exactCases := []string{
+ "DB_DSN=postgres://localhost/db",
+ "PGPASSWORD=secret",
+ "PGUSER=admin",
+ "PGHOST=db.internal",
+ "NPM_TOKEN=npm_abc123",
+ "NPM_CONFIG_TOKEN=token",
+ "HOMEBREW_GITHUB_API_TOKEN=ghp_abc",
+ "CODECOV_TOKEN=xxx",
+ "COVERALLS_REPO_TOKEN=yyy",
+ "SENTRY_DSN=https://sentry.io/...",
+ "SENTRY_AUTH_TOKEN=zzz",
+ "SECRET_KEY=supersecret",
+ "JWT_SECRET=jwtkey",
+ }
+ for _, envVar := range exactCases {
+ key := strings.SplitN(envVar, "=", 2)[0]
+ t.Run(key, func(t *testing.T) {
+ filtered := filterACPEnv([]string{envVar, "SAFE_VAR=ok"})
+ for _, f := range filtered {
+ if strings.HasPrefix(f, key+"=") {
+ t.Errorf("expected %q to be removed but it was kept", key)
+ }
+ }
+ })
+ }
+}
+
+func TestFilterACPEnv_EmptyInput(t *testing.T) {
+ result := filterACPEnv(nil)
+ if result != nil && len(result) != 0 {
+ t.Errorf("expected nil/empty result for nil input, got %v", result)
+ }
+}
+
+func TestFilterACPEnv_AllSafe(t *testing.T) {
+ env := []string{"HOME=/root", "USER=alice", "TERM=xterm-256color", "LANG=en_US.UTF-8"}
+ filtered := filterACPEnv(env)
+ if len(filtered) != len(env) {
+ t.Errorf("expected all %d vars kept, got %d", len(env), len(filtered))
+ }
+}
+
+func TestFilterACPEnv_CaseInsensitive(t *testing.T) {
+ // keys are uppercased before comparison
+ env := []string{"goclaw_token=abc", "SAFE=ok"}
+ filtered := filterACPEnv(env)
+ for _, f := range filtered {
+ if strings.HasPrefix(strings.ToUpper(f), "GOCLAW_") {
+ t.Errorf("expected lowercase goclaw_token to be stripped, got %q", f)
+ }
+ }
+}
+
+func TestFilterACPEnv_NoEqualsSign(t *testing.T) {
+ // Entries with no "=" should be treated as key with empty value; key is full string
+ env := []string{"PLAINVAR", "HOME=/home/user"}
+ filtered := filterACPEnv(env)
+ // PLAINVAR is not sensitive, should be kept
+ found := false
+ for _, f := range filtered {
+ if f == "PLAINVAR" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("expected PLAINVAR (no =) to be kept")
+ }
+}
+
+// --- limitedWriter tests ---
+
+func TestLimitedWriter_CapsToBudget(t *testing.T) {
+ lw := &limitedWriter{max: 10}
+ n, err := lw.Write([]byte("hello world"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // Write truncates p to remaining capacity before return, so n == min(len(p), max)
+ if n != 10 {
+ t.Errorf("expected n=10 (capped to max), got %d", n)
+ }
+ // Only the first 10 bytes are kept
+ if got := lw.String(); got != "hello worl" {
+ t.Errorf("expected %q, got %q", "hello worl", got)
+ }
+}
+
+func TestLimitedWriter_StopsAfterFull(t *testing.T) {
+ lw := &limitedWriter{max: 5}
+ lw.Write([]byte("12345"))
+ lw.Write([]byte("extra"))
+ if got := lw.String(); got != "12345" {
+ t.Errorf("expected %q, got %q", "12345", got)
+ }
+}
+
+func TestLimitedWriter_ExactMax(t *testing.T) {
+ lw := &limitedWriter{max: 4}
+ lw.Write([]byte("ab"))
+ lw.Write([]byte("cd"))
+ if got := lw.String(); got != "abcd" {
+ t.Errorf("expected %q, got %q", "abcd", got)
+ }
+}
+
+func TestLimitedWriter_ZeroMax(t *testing.T) {
+ lw := &limitedWriter{max: 0}
+ n, err := lw.Write([]byte("hello"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if n != 5 {
+ t.Errorf("expected n=5, got %d", n)
+ }
+ if got := lw.String(); got != "" {
+ t.Errorf("expected empty string, got %q", got)
+ }
+}
+
+func TestLimitedWriter_ConcurrentWrites(t *testing.T) {
+ lw := &limitedWriter{max: 1000}
+ var wg sync.WaitGroup
+ for i := 0; i < 20; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ lw.Write([]byte("data"))
+ }()
+ }
+ wg.Wait()
+ // No panic and output <= max
+ s := lw.String()
+ if len(s) > 1000 {
+ t.Errorf("expected len <= 1000, got %d", len(s))
+ }
+}
+
+func TestLimitedWriter_ImplementsWriter(t *testing.T) {
+ // compile-time check already in helpers.go but verify via interface assignment
+ var _ interface{ Write([]byte) (int, error) } = (*limitedWriter)(nil)
+}
diff --git a/internal/providers/acp/jsonrpc_test.go b/internal/providers/acp/jsonrpc_test.go
new file mode 100644
index 00000000..98fb3580
--- /dev/null
+++ b/internal/providers/acp/jsonrpc_test.go
@@ -0,0 +1,524 @@
+package acp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+ "testing"
+ "time"
+)
+
+// --- jsonrpcError tests ---
+
+func TestJsonrpcError_Error(t *testing.T) {
+ e := &jsonrpcError{Code: -32601, Message: "method not found"}
+ got := e.Error()
+ if !strings.Contains(got, "-32601") {
+ t.Errorf("expected code in error string, got %q", got)
+ }
+ if !strings.Contains(got, "method not found") {
+ t.Errorf("expected message in error string, got %q", got)
+ }
+}
+
+// --- Conn read loop + dispatch tests ---
+
+// makeConnPair creates two in-memory Conn pairs communicating via io.Pipe.
+// Returns (clientConn, serverReader, serverWriter) where:
+// - clientConn reads from serverWriter and writes to serverReader
+// - the test can inject messages by writing to serverWriter
+// - the test can read messages by reading from serverReader
+func makeLoopbackConn(handler RequestHandler, notify NotifyHandler) (*Conn, io.Writer, io.ReadCloser) {
+ // client writes → serverR reads (for the test to inspect outbound)
+ serverR, clientW := io.Pipe()
+ // serverW writes → client reads (for the test to inject inbound)
+ clientR, serverW := io.Pipe()
+
+ conn := NewConn(clientW, clientR, handler, notify)
+ return conn, serverW, serverR
+}
+
+func TestNewConn_NotNil(t *testing.T) {
+ pr, pw := io.Pipe()
+ defer pr.Close()
+ defer pw.Close()
+ conn := NewConn(pw, pr, nil, nil)
+ if conn == nil {
+ t.Fatal("expected non-nil Conn")
+ }
+}
+
+func TestConn_Done_ClosedOnReaderEOF(t *testing.T) {
+ pr, pw := io.Pipe()
+ conn := NewConn(io.Discard, pr, nil, nil)
+ conn.Start()
+
+ // Close the write end to signal EOF → read loop exits → done closed
+ pw.Close()
+
+ select {
+ case <-conn.Done():
+ // expected
+ case <-time.After(2 * time.Second):
+ t.Fatal("expected Done to be closed after reader EOF")
+ }
+}
+
+func TestConn_Notify_WritesMessage(t *testing.T) {
+ // We only need a writer; reader can be /dev/null equiv
+ pr, pw := io.Pipe()
+ defer pr.Close()
+
+ // Write to a buffer-based pipe so we can inspect
+ outR, outW := io.Pipe()
+
+ conn := NewConn(outW, pr, nil, nil)
+ // Don't start read loop — we're only testing write path
+
+ done := make(chan []byte, 1)
+ go func() {
+ data, _ := io.ReadAll(outR)
+ done <- data
+ }()
+
+ err := conn.Notify("test/event", map[string]string{"key": "val"})
+ if err != nil {
+ t.Fatalf("Notify error: %v", err)
+ }
+ outW.Close()
+ pw.Close()
+
+ select {
+ case data := <-done:
+ if !strings.Contains(string(data), "test/event") {
+ t.Errorf("expected method in output, got %q", string(data))
+ }
+ if !strings.Contains(string(data), `"jsonrpc":"2.0"`) {
+ t.Errorf("expected jsonrpc version in output, got %q", string(data))
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timeout reading notify output")
+ }
+}
+
+func TestConn_Call_ResponseDispatched(t *testing.T) {
+ // serverW → clientR (injecting responses); clientW → serverR (reading requests)
+ clientR, serverW := io.Pipe()
+ serverR, clientW := io.Pipe()
+
+ conn := NewConn(clientW, clientR, nil, nil)
+ conn.Start()
+
+ // Read the request the client sends, then write the response back.
+ go func() {
+ buf := make([]byte, 4096)
+ n, err := serverR.Read(buf)
+ if err != nil {
+ return
+ }
+ var req jsonrpcMessage
+ json.Unmarshal(buf[:n], &req)
+
+ // Send response with same ID
+ resp := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: req.ID,
+ Result: json.RawMessage(`{"sessionId":"sess-1"}`),
+ }
+ data, _ := json.Marshal(resp)
+ data = append(data, '\n')
+ serverW.Write(data)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ var result NewSessionResponse
+ err := conn.Call(ctx, "session/new", NewSessionRequest{}, &result)
+ if err != nil {
+ t.Fatalf("Call error: %v", err)
+ }
+ if result.SessionID != "sess-1" {
+ t.Errorf("expected sess-1, got %q", result.SessionID)
+ }
+
+ serverW.Close()
+ serverR.Close()
+}
+
+func TestConn_Call_ErrorResponse(t *testing.T) {
+ clientR, serverW := io.Pipe()
+ serverR, clientW := io.Pipe()
+
+ conn := NewConn(clientW, clientR, nil, nil)
+ conn.Start()
+
+ go func() {
+ buf := make([]byte, 4096)
+ n, _ := serverR.Read(buf)
+ var req jsonrpcMessage
+ json.Unmarshal(buf[:n], &req)
+
+ resp := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: req.ID,
+ Error: &jsonrpcError{Code: -32600, Message: "invalid request"},
+ }
+ data, _ := json.Marshal(resp)
+ data = append(data, '\n')
+ serverW.Write(data)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ err := conn.Call(ctx, "bad/method", nil, nil)
+ if err == nil {
+ t.Fatal("expected error response, got nil")
+ }
+ if !strings.Contains(err.Error(), "invalid request") {
+ t.Errorf("expected 'invalid request' in error, got %q", err.Error())
+ }
+
+ serverW.Close()
+ serverR.Close()
+}
+
+func TestConn_Call_ContextCancel(t *testing.T) {
+ // Server accepts writes but never sends responses.
+ clientR, serverW := io.Pipe()
+ serverR, clientW := io.Pipe()
+
+ conn := NewConn(clientW, clientR, nil, nil)
+ conn.Start()
+
+ // Drain the client writes so writeMessage doesn't block; never send a response.
+ go func() {
+ buf := make([]byte, 4096)
+ for {
+ _, err := serverR.Read(buf)
+ if err != nil {
+ return
+ }
+ }
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ err := conn.Call(ctx, "slow/method", nil, nil)
+ if err == nil {
+ t.Fatal("expected context timeout error")
+ }
+
+ serverW.Close()
+ serverR.Close()
+}
+
+func TestConn_Call_ConnectionClosed(t *testing.T) {
+ clientR, serverW := io.Pipe()
+ serverR, clientW := io.Pipe()
+
+ conn := NewConn(clientW, clientR, nil, nil)
+ conn.Start()
+
+ // Close the server write end immediately — causes EOF → done closed
+ go func() {
+ // Drain the request first so the client write doesn't block
+ buf := make([]byte, 4096)
+ serverR.Read(buf)
+ serverW.Close()
+ }()
+
+ ctx := context.Background()
+ err := conn.Call(ctx, "any/method", nil, nil)
+ if err == nil {
+ t.Fatal("expected error after connection close")
+ }
+}
+
+func TestConn_ReadLoop_SkipsMalformedJSON(t *testing.T) {
+ clientR, serverW := io.Pipe()
+ serverR, clientW := io.Pipe()
+ _ = serverR
+
+ var notifyCalled bool
+ notifyDone := make(chan struct{})
+ notify := func(method string, params json.RawMessage) {
+ if method == "good/event" {
+ notifyCalled = true
+ close(notifyDone)
+ }
+ }
+
+ conn := NewConn(clientW, clientR, nil, notify)
+ conn.Start()
+
+ // Send malformed line, then valid notification
+ serverW.Write([]byte("{{not valid json}}\n"))
+ goodMsg := jsonrpcMessage{JSONRPC: "2.0", Method: "good/event", Params: json.RawMessage(`{}`)}
+ data, _ := json.Marshal(goodMsg)
+ serverW.Write(append(data, '\n'))
+
+ select {
+ case <-notifyDone:
+ if !notifyCalled {
+ t.Error("expected notify to be called after malformed line")
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timeout waiting for good/event notification")
+ }
+
+ serverW.Close()
+ clientW.Close()
+}
+
+func TestConn_ReadLoop_EmptyLines(t *testing.T) {
+ clientR, serverW := io.Pipe()
+ _, clientW := io.Pipe()
+
+ notifyDone := make(chan struct{})
+ notify := func(method string, params json.RawMessage) {
+ if method == "ping" {
+ close(notifyDone)
+ }
+ }
+
+ conn := NewConn(clientW, clientR, nil, notify)
+ conn.Start()
+
+ // Send empty lines then a valid notification
+ serverW.Write([]byte("\n\n\n"))
+ msg := jsonrpcMessage{JSONRPC: "2.0", Method: "ping"}
+ data, _ := json.Marshal(msg)
+ serverW.Write(append(data, '\n'))
+
+ select {
+ case <-notifyDone:
+ case <-time.After(2 * time.Second):
+ t.Fatal("timeout: notification after empty lines not received")
+ }
+ serverW.Close()
+}
+
+func TestConn_ReadLoop_HandlesRequestFromAgent(t *testing.T) {
+ clientR, serverW := io.Pipe()
+ serverR, clientW := io.Pipe()
+
+ handlerCalled := make(chan string, 1)
+ handler := func(ctx context.Context, method string, params json.RawMessage) (any, error) {
+ handlerCalled <- method
+ return map[string]string{"result": "ok"}, nil
+ }
+
+ conn := NewConn(clientW, clientR, handler, nil)
+ conn.Start()
+
+ // Agent sends a request (has ID + method)
+ id := int64(99)
+ req := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: &id,
+ Method: "fs/readTextFile",
+ Params: json.RawMessage(`{"path":"/tmp/test"}`),
+ }
+ data, _ := json.Marshal(req)
+ serverW.Write(append(data, '\n'))
+
+ select {
+ case method := <-handlerCalled:
+ if method != "fs/readTextFile" {
+ t.Errorf("expected fs/readTextFile, got %q", method)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timeout: handler not called")
+ }
+
+ // Drain the response
+ go func() {
+ buf := make([]byte, 4096)
+ serverR.Read(buf)
+ }()
+
+ serverW.Close()
+}
+
+func TestConn_ReadLoop_NoHandlerReturnsError(t *testing.T) {
+ clientR, serverW := io.Pipe()
+ serverR, clientW := io.Pipe()
+
+ conn := NewConn(clientW, clientR, nil, nil) // no handler
+ conn.Start()
+
+ id := int64(1)
+ req := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: &id,
+ Method: "fs/readTextFile",
+ Params: json.RawMessage(`{}`),
+ }
+ data, _ := json.Marshal(req)
+ serverW.Write(append(data, '\n'))
+
+ // Read the error response
+ respDone := make(chan *jsonrpcMessage, 1)
+ go func() {
+ buf := make([]byte, 4096)
+ n, _ := serverR.Read(buf)
+ var msg jsonrpcMessage
+ json.Unmarshal(buf[:n], &msg)
+ respDone <- &msg
+ }()
+
+ select {
+ case resp := <-respDone:
+ if resp.Error == nil {
+ t.Error("expected error response when no handler registered")
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timeout waiting for error response")
+ }
+
+ serverW.Close()
+}
+
+func TestConn_Notify_MarshalError(t *testing.T) {
+ // Use a writer that always errors to test error return
+ pr, _ := io.Pipe()
+ pw := &errorWriter{}
+ conn := NewConn(pw, pr, nil, nil)
+
+ err := conn.Notify("test", map[string]string{"k": "v"})
+ if err == nil {
+ t.Error("expected error from failing writer")
+ }
+}
+
+// errorWriter always returns an error on Write.
+type errorWriter struct{}
+
+func (w *errorWriter) Write(p []byte) (int, error) {
+ return 0, fmt.Errorf("forced write error")
+}
+
+func TestConn_Call_NilResult(t *testing.T) {
+ clientR, serverW := io.Pipe()
+ serverR, clientW := io.Pipe()
+
+ conn := NewConn(clientW, clientR, nil, nil)
+ conn.Start()
+
+ go func() {
+ buf := make([]byte, 4096)
+ n, _ := serverR.Read(buf)
+ var req jsonrpcMessage
+ json.Unmarshal(buf[:n], &req)
+ resp := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: req.ID,
+ Result: json.RawMessage(`{}`),
+ }
+ data, _ := json.Marshal(resp)
+ serverW.Write(append(data, '\n'))
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ // nil result pointer — should not panic
+ err := conn.Call(ctx, "ping", nil, nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ serverW.Close()
+ serverR.Close()
+}
+
+// TestConn_ReadLoop_MalformedInputNoPanic verifies adversarial inputs don't panic.
+func TestConn_ReadLoop_MalformedInputNoPanic(t *testing.T) {
+ adversarialInputs := []string{
+ "null\n",
+ "[]\n",
+ `{"id":null,"method":null}\n`,
+ strings.Repeat("x", 1024) + "\n",
+ `{"jsonrpc":"2.0","id":1,"method":` + strings.Repeat(`"a"`, 100) + `}\n`,
+ "\x00\x01\x02\x03\n",
+ `{"jsonrpc":"2.0","result":` + strings.Repeat("[", 100) + "\n",
+ }
+
+ for _, input := range adversarialInputs {
+ t.Run("input_"+input[:min(10, len(input))], func(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Errorf("panic on input %q: %v", input[:min(20, len(input))], r)
+ }
+ }()
+
+ clientR, serverW := io.Pipe()
+ _, clientW := io.Pipe()
+
+ conn := NewConn(clientW, clientR, nil, nil)
+ conn.Start()
+
+ serverW.Write([]byte(input))
+ // Small delay for read loop to process
+ time.Sleep(10 * time.Millisecond)
+ serverW.Close()
+ })
+ }
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func TestConn_IDIncrement(t *testing.T) {
+ // Verify each Call gets a unique ID by capturing two requests
+ clientR, serverW := io.Pipe()
+ serverR, clientW := io.Pipe()
+
+ conn := NewConn(clientW, clientR, nil, nil)
+ conn.Start()
+
+ ids := make(chan int64, 2)
+
+ // Goroutine that reads requests and responds
+ go func() {
+ for i := 0; i < 2; i++ {
+ buf := make([]byte, 4096)
+ n, _ := serverR.Read(buf)
+ var req jsonrpcMessage
+ json.Unmarshal(buf[:n], &req)
+ if req.ID != nil {
+ ids <- *req.ID
+ }
+ resp := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: req.ID,
+ Result: json.RawMessage(`{}`),
+ }
+ data, _ := json.Marshal(resp)
+ serverW.Write(append(data, '\n'))
+ }
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ conn.Call(ctx, "m1", nil, nil)
+ conn.Call(ctx, "m2", nil, nil)
+
+ id1 := <-ids
+ id2 := <-ids
+ if id1 == id2 {
+ t.Errorf("expected different IDs, got %d and %d", id1, id2)
+ }
+
+ serverW.Close()
+ serverR.Close()
+}
diff --git a/internal/providers/acp/process_test.go b/internal/providers/acp/process_test.go
new file mode 100644
index 00000000..c98a32bb
--- /dev/null
+++ b/internal/providers/acp/process_test.go
@@ -0,0 +1,264 @@
+package acp
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+// --- ProcessPool construction ---
+
+func TestNewProcessPool_Fields(t *testing.T) {
+ pp := NewProcessPool("/usr/bin/claude", []string{"--acp"}, "/workspace", 5*time.Minute)
+ if pp == nil {
+ t.Fatal("expected non-nil ProcessPool")
+ }
+ if pp.agentBinary != "/usr/bin/claude" {
+ t.Errorf("agentBinary: got %q", pp.agentBinary)
+ }
+ if len(pp.agentArgs) != 1 || pp.agentArgs[0] != "--acp" {
+ t.Errorf("agentArgs: got %v", pp.agentArgs)
+ }
+ if pp.workDir != "/workspace" {
+ t.Errorf("workDir: got %q", pp.workDir)
+ }
+ if pp.idleTTL != 5*time.Minute {
+ t.Errorf("idleTTL: got %v", pp.idleTTL)
+ }
+ // Clean up reapLoop goroutine
+ pp.Close()
+}
+
+func TestNewProcessPool_DoneChannelOpen(t *testing.T) {
+ pp := NewProcessPool("x", nil, "", time.Minute)
+ select {
+ case <-pp.done:
+ t.Error("done channel should not be closed before Close()")
+ default:
+ }
+ pp.Close()
+}
+
+// --- SetToolHandler / getToolHandler ---
+
+func TestProcessPool_SetToolHandler(t *testing.T) {
+ pp := NewProcessPool("x", nil, "", time.Minute)
+ defer pp.Close()
+
+ if pp.getToolHandler() != nil {
+ t.Error("expected nil handler before SetToolHandler")
+ }
+
+ called := make(chan string, 1)
+ h := RequestHandler(func(ctx context.Context, method string, params json.RawMessage) (any, error) {
+ called <- method
+ return nil, nil
+ })
+ pp.SetToolHandler(h)
+
+ if pp.getToolHandler() == nil {
+ t.Error("expected non-nil handler after SetToolHandler")
+ }
+}
+
+// --- Close ---
+
+func TestProcessPool_Close_Idempotent(t *testing.T) {
+ pp := NewProcessPool("x", nil, "", time.Minute)
+ // Close twice must not panic or deadlock
+ if err := pp.Close(); err != nil {
+ t.Fatalf("first Close error: %v", err)
+ }
+ if err := pp.Close(); err != nil {
+ t.Fatalf("second Close error: %v", err)
+ }
+}
+
+func TestProcessPool_Close_DrainsDoneChannel(t *testing.T) {
+ pp := NewProcessPool("x", nil, "", time.Minute)
+ pp.Close()
+
+ select {
+ case <-pp.done:
+ // expected — done closed by Close()
+ case <-time.After(time.Second):
+ t.Fatal("expected done channel to be closed after Close()")
+ }
+}
+
+func TestProcessPool_Close_WithFakeProcesses(t *testing.T) {
+ pp := NewProcessPool("x", nil, "", time.Minute)
+
+ // Insert fake ACPProcess entries that are already exited
+ for i := 0; i < 3; i++ {
+ exitedCh := make(chan struct{})
+ close(exitedCh) // pre-exited
+ proc := &ACPProcess{
+ exited: exitedCh,
+ cancel: func() {},
+ ctx: context.Background(),
+ }
+ // Wire a no-op Conn so Close doesn't need real pipes
+ proc.conn = &Conn{done: make(chan struct{})}
+ pp.processes.Store(i, proc)
+ }
+
+ done := make(chan error, 1)
+ go func() { done <- pp.Close() }()
+
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("Close with pre-exited processes: %v", err)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("Close timed out with pre-exited fake processes")
+ }
+}
+
+func TestProcessPool_Close_TimesOutSlowProcess(t *testing.T) {
+ pp := NewProcessPool("x", nil, "", time.Minute)
+
+ // Insert a fake process that never exits (simulates slow shutdown)
+ neverExit := make(chan struct{}) // never closed
+ proc := &ACPProcess{
+ exited: neverExit,
+ cancel: func() {},
+ ctx: context.Background(),
+ }
+ proc.conn = &Conn{done: make(chan struct{})}
+ pp.processes.Store("slow", proc)
+
+ // Override the 5s timeout by closing pp.done directly to trigger Close fast.
+ // We test that Close() returns even when a process doesn't exit.
+ // Use a very short real test — Close() internally waits up to 5s per process.
+ // Instead, verify that Close does not block indefinitely by checking it finishes
+ // within a reasonable window (the pool's internal 5s + buffer).
+ done := make(chan error, 1)
+ go func() { done <- pp.Close() }()
+
+ // The test just needs to complete; the real 5s wait happens internally.
+ // Use a long timeout to avoid flakiness on slow CI.
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("Close returned error: %v", err)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("Close did not return within 10 seconds for slow process")
+ }
+}
+
+// --- reapLoop behaviour (via pool state manipulation) ---
+
+func TestProcessPool_ReapLoop_SkipsActivePrompts(t *testing.T) {
+ pp := NewProcessPool("x", nil, "", 1*time.Millisecond) // very short TTL
+ // Use a pre-exited channel so Close() doesn't wait 5s
+ exitedCh := make(chan struct{})
+ close(exitedCh)
+
+ proc := &ACPProcess{
+ exited: exitedCh,
+ cancel: func() {},
+ ctx: context.Background(),
+ lastActive: time.Now().Add(-time.Hour), // very old
+ }
+ proc.conn = &Conn{done: make(chan struct{})}
+ proc.inUse.Store(1) // active prompt — reaper must skip
+ pp.processes.Store("active-key", proc)
+ defer pp.Close()
+
+ // Wait more than the reap interval (30s internal ticker — can't shorten without
+ // interface injection). Instead, exercise the Range callback directly by calling
+ // reapLoop's logic inline as a unit.
+ //
+ // The reapLoop uses a 30s ticker, so we exercise the logic directly:
+ pp.processes.Range(func(key, value any) bool {
+ p := value.(*ACPProcess)
+ if p.inUse.Load() > 0 {
+ return true // should skip
+ }
+ t.Errorf("should not reap process with active prompt, key=%v", key)
+ return true
+ })
+
+ if _, ok := pp.processes.Load("active-key"); !ok {
+ t.Error("active process should not have been removed")
+ }
+}
+
+func TestProcessPool_GetOrSpawn_ReturnsExisting(t *testing.T) {
+ pp := NewProcessPool("x", nil, "", time.Minute)
+ // Pre-insert a fake running process — exited channel intentionally never closed
+ exitedCh := make(chan struct{}) // open = "still running"
+ proc := &ACPProcess{
+ exited: exitedCh,
+ cancel: func() {},
+ ctx: context.Background(),
+ }
+ proc.conn = &Conn{done: make(chan struct{})}
+ pp.processes.Store("sess-existing", proc)
+ // Remove the stalling process before Close so cleanup is fast
+ t.Cleanup(func() {
+ pp.processes.Delete("sess-existing")
+ pp.Close()
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ got, err := pp.GetOrSpawn(ctx, "sess-existing")
+ if err != nil {
+ t.Fatalf("GetOrSpawn error: %v", err)
+ }
+ if got != proc {
+ t.Error("expected same *ACPProcess to be returned from pool")
+ }
+}
+
+func TestProcessPool_GetOrSpawn_RespawnsExited(t *testing.T) {
+ // When stored process has exited channel closed, GetOrSpawn should attempt
+ // to respawn. The respawn will fail (no real binary) — verify it attempts
+ // and the stale entry is removed.
+ pp := NewProcessPool("/nonexistent-binary-xyz", nil, t.TempDir(), time.Minute)
+ defer pp.Close()
+
+ exitedCh := make(chan struct{})
+ close(exitedCh) // pre-exited = crashed
+
+ proc := &ACPProcess{
+ exited: exitedCh,
+ cancel: func() {},
+ ctx: context.Background(),
+ }
+ proc.conn = &Conn{done: make(chan struct{})}
+ pp.processes.Store("sess-crashed", proc)
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ _, err := pp.GetOrSpawn(ctx, "sess-crashed")
+ // Expect an error because the binary doesn't exist
+ if err == nil {
+ t.Fatal("expected error when respawning with nonexistent binary")
+ }
+
+ // Stale entry should have been removed before the failed spawn
+ if _, ok := pp.processes.Load("sess-crashed"); ok {
+ t.Error("expected stale process entry to be removed after crash detection")
+ }
+}
+
+func TestProcessPool_GetOrSpawn_SpawnFailsGracefully(t *testing.T) {
+ pp := NewProcessPool("/definitely-does-not-exist", nil, t.TempDir(), time.Minute)
+ defer pp.Close()
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ _, err := pp.GetOrSpawn(ctx, "new-session")
+ if err == nil {
+ t.Fatal("expected error when binary does not exist")
+ }
+}
diff --git a/internal/providers/acp/session_test.go b/internal/providers/acp/session_test.go
new file mode 100644
index 00000000..8ba8f41f
--- /dev/null
+++ b/internal/providers/acp/session_test.go
@@ -0,0 +1,434 @@
+package acp
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "strings"
+ "testing"
+ "time"
+)
+
+// buildACPProcess creates an ACPProcess wired to an in-process io.Pipe conn.
+// Returns the process, a writer the test can inject responses into (serverW),
+// and a reader the test can read outbound requests from (serverR).
+//
+// The returned serverR is a *bufferedPipeReader that always drains — so the
+// conn's writeMessage never blocks even if the test ignores outbound bytes.
+//
+// clientW → serverR (test reads what the process sends)
+// serverW → clientR (test injects responses to the process)
+func buildACPProcess(handler RequestHandler, notify NotifyHandler) (
+ proc *ACPProcess,
+ serverW io.WriteCloser,
+ serverR io.ReadCloser,
+) {
+ clientR, sW := io.Pipe() // test writes here → conn reads
+ sR, clientW := io.Pipe() // conn writes here → test reads
+
+ conn := NewConn(clientW, clientR, handler, notify)
+ conn.Start()
+
+ proc = &ACPProcess{
+ conn: conn,
+ exited: make(chan struct{}),
+ ctx: context.Background(),
+ cancel: func() {},
+ }
+ return proc, sW, sR
+}
+
+// drainPipe reads sR in background so writes to clientW never block.
+// Returns a channel that receives each complete line written by the conn.
+func drainPipeLines(sR io.ReadCloser) chan []byte {
+ ch := make(chan []byte, 64)
+ go func() {
+ defer close(ch)
+ buf := make([]byte, 32*1024)
+ for {
+ n, err := sR.Read(buf)
+ if n > 0 {
+ line := make([]byte, n)
+ copy(line, buf[:n])
+ select {
+ case ch <- line:
+ default:
+ }
+ }
+ if err != nil {
+ return
+ }
+ }
+ }()
+ return ch
+}
+
+// replyTo reads one JSON-RPC request from r, then writes a response with the
+// given result JSON back to w. Runs in its own goroutine and signals done.
+func replyTo(r io.Reader, w io.Writer, resultJSON string) chan struct{} {
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ buf := make([]byte, 32*1024)
+ n, err := r.Read(buf)
+ if err != nil || n == 0 {
+ return
+ }
+ var req jsonrpcMessage
+ if err := json.Unmarshal(buf[:n], &req); err != nil {
+ return
+ }
+ resp := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: req.ID,
+ Result: json.RawMessage(resultJSON),
+ }
+ data, _ := json.Marshal(resp)
+ w.Write(append(data, '\n'))
+ }()
+ return done
+}
+
+// replyError reads one request and sends back a JSON-RPC error response.
+func replyError(r io.Reader, w io.Writer, code int, message string) chan struct{} {
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ buf := make([]byte, 32*1024)
+ n, _ := r.Read(buf)
+ if n == 0 {
+ return
+ }
+ var req jsonrpcMessage
+ json.Unmarshal(buf[:n], &req)
+ resp := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: req.ID,
+ Error: &jsonrpcError{Code: code, Message: message},
+ }
+ data, _ := json.Marshal(resp)
+ w.Write(append(data, '\n'))
+ }()
+ return done
+}
+
+// --- Initialize tests ---
+
+func TestACPProcess_Initialize_Success(t *testing.T) {
+ proc, serverW, serverR := buildACPProcess(nil, nil)
+ defer serverW.Close()
+ defer serverR.Close()
+
+ respJSON := `{"agentInfo":{"name":"claude","version":"1.0"},"capabilities":{"loadSession":true}}`
+ done := replyTo(serverR, serverW, respJSON)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ if err := proc.Initialize(ctx); err != nil {
+ t.Fatalf("Initialize error: %v", err)
+ }
+ if !proc.agentCaps.LoadSession {
+ t.Error("expected LoadSession=true after Initialize")
+ }
+
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("timeout waiting for replyTo goroutine")
+ }
+}
+
+func TestACPProcess_Initialize_ErrorResponse(t *testing.T) {
+ proc, serverW, serverR := buildACPProcess(nil, nil)
+ defer serverW.Close()
+ defer serverR.Close()
+
+ done := replyError(serverR, serverW, -32600, "not supported")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ err := proc.Initialize(ctx)
+ if err == nil {
+ t.Fatal("expected error from Initialize")
+ }
+ if !strings.Contains(err.Error(), "acp initialize") {
+ t.Errorf("expected 'acp initialize' prefix, got %q", err.Error())
+ }
+
+ <-done
+}
+
+func TestACPProcess_Initialize_Timeout(t *testing.T) {
+ proc, serverW, serverR := buildACPProcess(nil, nil)
+ defer serverW.Close()
+
+ // Drain requests so writeMessage doesn't block, but never send a response.
+ go func() {
+ buf := make([]byte, 4096)
+ for {
+ _, err := serverR.Read(buf)
+ if err != nil {
+ return
+ }
+ }
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+
+ err := proc.Initialize(ctx)
+ if err == nil {
+ t.Fatal("expected timeout error")
+ }
+ serverR.Close()
+}
+
+// --- NewSession tests ---
+
+func TestACPProcess_NewSession_Success(t *testing.T) {
+ proc, serverW, serverR := buildACPProcess(nil, nil)
+ defer serverW.Close()
+ defer serverR.Close()
+
+ done := replyTo(serverR, serverW, `{"sessionId":"sess-xyz"}`)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ if err := proc.NewSession(ctx); err != nil {
+ t.Fatalf("NewSession error: %v", err)
+ }
+ if proc.sessionID != "sess-xyz" {
+ t.Errorf("expected sessionID='sess-xyz', got %q", proc.sessionID)
+ }
+ <-done
+}
+
+func TestACPProcess_NewSession_Error(t *testing.T) {
+ proc, serverW, serverR := buildACPProcess(nil, nil)
+ defer serverW.Close()
+ defer serverR.Close()
+
+ done := replyError(serverR, serverW, -32000, "session failed")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ err := proc.NewSession(ctx)
+ if err == nil {
+ t.Fatal("expected error from NewSession")
+ }
+ if !strings.Contains(err.Error(), "acp session/new") {
+ t.Errorf("expected 'acp session/new' prefix, got %q", err.Error())
+ }
+ <-done
+}
+
+// --- Prompt tests ---
+
+func TestACPProcess_Prompt_Success(t *testing.T) {
+ proc, serverW, serverR := buildACPProcess(nil, nil)
+ proc.sessionID = "sess-1"
+ defer serverW.Close()
+ defer serverR.Close()
+
+ done := replyTo(serverR, serverW, `{"stopReason":"endTurn"}`)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ resp, err := proc.Prompt(ctx, []ContentBlock{{Type: "text", Text: "hi"}}, nil)
+ if err != nil {
+ t.Fatalf("Prompt error: %v", err)
+ }
+ if resp.StopReason != "endTurn" {
+ t.Errorf("expected stopReason='endTurn', got %q", resp.StopReason)
+ }
+ <-done
+}
+
+func TestACPProcess_Prompt_WithUpdateCallback(t *testing.T) {
+ updates := make(chan SessionUpdate, 10)
+ notifyHandler := func(method string, params json.RawMessage) {
+ if method == "session/update" {
+ var u SessionUpdate
+ json.Unmarshal(params, &u)
+ updates <- u
+ }
+ }
+
+ proc, serverW, serverR := buildACPProcess(nil, notifyHandler)
+ proc.sessionID = "sess-2"
+ defer serverW.Close()
+ defer serverR.Close()
+
+ // Goroutine: send a notification, then a response
+ go func() {
+ // Read the prompt request first
+ buf := make([]byte, 32*1024)
+ n, _ := serverR.Read(buf)
+ var req jsonrpcMessage
+ json.Unmarshal(buf[:n], &req)
+
+ // Send session/update notification
+ notif := jsonrpcMessage{
+ JSONRPC: "2.0",
+ Method: "session/update",
+ Params: json.RawMessage(`{"kind":"message","stopReason":""}`),
+ }
+ nd, _ := json.Marshal(notif)
+ serverW.Write(append(nd, '\n'))
+
+ // Send prompt response
+ resp := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: req.ID,
+ Result: json.RawMessage(`{"stopReason":"endTurn"}`),
+ }
+ rd, _ := json.Marshal(resp)
+ serverW.Write(append(rd, '\n'))
+ }()
+
+ var received []SessionUpdate
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ _, err := proc.Prompt(ctx, []ContentBlock{{Type: "text", Text: "hello"}}, func(u SessionUpdate) {
+ received = append(received, u)
+ })
+ if err != nil {
+ t.Fatalf("Prompt error: %v", err)
+ }
+
+ // Drain notification channel
+ time.Sleep(20 * time.Millisecond)
+ // The update callback + notify handler may both fire — just check no panic occurred
+}
+
+func TestACPProcess_Prompt_Error(t *testing.T) {
+ proc, serverW, serverR := buildACPProcess(nil, nil)
+ proc.sessionID = "sess-3"
+ defer serverW.Close()
+ defer serverR.Close()
+
+ done := replyError(serverR, serverW, -32000, "prompt failed")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ _, err := proc.Prompt(ctx, []ContentBlock{{Type: "text", Text: "hi"}}, nil)
+ if err == nil {
+ t.Fatal("expected error from Prompt")
+ }
+ if !strings.Contains(err.Error(), "acp session/prompt") {
+ t.Errorf("expected 'acp session/prompt' prefix, got %q", err.Error())
+ }
+ <-done
+}
+
+func TestACPProcess_Prompt_SetsInUse(t *testing.T) {
+ // Verify inUse counter goes up during Prompt and back to 0 after
+ proc, serverW, serverR := buildACPProcess(nil, nil)
+ proc.sessionID = "sess-4"
+ defer serverW.Close()
+ defer serverR.Close()
+
+ started := make(chan struct{})
+ go func() {
+ // Read and wait a moment before responding
+ buf := make([]byte, 32*1024)
+ n, _ := serverR.Read(buf)
+ var req jsonrpcMessage
+ json.Unmarshal(buf[:n], &req)
+
+ close(started)
+ time.Sleep(20 * time.Millisecond)
+
+ resp := jsonrpcMessage{
+ JSONRPC: "2.0",
+ ID: req.ID,
+ Result: json.RawMessage(`{"stopReason":"endTurn"}`),
+ }
+ data, _ := json.Marshal(resp)
+ serverW.Write(append(data, '\n'))
+ }()
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc.Prompt(ctx, []ContentBlock{{Type: "text", Text: "x"}}, nil)
+ }()
+
+ <-started
+ if proc.inUse.Load() != 1 {
+ t.Errorf("expected inUse=1 during Prompt, got %d", proc.inUse.Load())
+ }
+
+ <-done
+ if proc.inUse.Load() != 0 {
+ t.Errorf("expected inUse=0 after Prompt, got %d", proc.inUse.Load())
+ }
+}
+
+// --- Cancel tests ---
+
+func TestACPProcess_Cancel_SendsNotification(t *testing.T) {
+ proc, serverW, serverR := buildACPProcess(nil, nil)
+ proc.sessionID = "sess-cancel"
+ defer serverW.Close()
+
+ received := make(chan string, 1)
+ go func() {
+ buf := make([]byte, 4096)
+ n, _ := serverR.Read(buf)
+ var msg jsonrpcMessage
+ json.Unmarshal(buf[:n], &msg)
+ received <- msg.Method
+ }()
+
+ err := proc.Cancel()
+ if err != nil {
+ t.Fatalf("Cancel error: %v", err)
+ }
+
+ select {
+ case method := <-received:
+ if method != "session/cancel" {
+ t.Errorf("expected 'session/cancel', got %q", method)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timeout waiting for cancel notification")
+ }
+}
+
+// --- dispatchUpdate / setUpdateFn ---
+
+func TestACPProcess_DispatchUpdate_NilFn(t *testing.T) {
+ proc := &ACPProcess{}
+ // Should not panic with nil updateFn
+ proc.dispatchUpdate(SessionUpdate{Kind: "message"})
+}
+
+func TestACPProcess_DispatchUpdate_CallsFn(t *testing.T) {
+ proc := &ACPProcess{}
+ called := false
+ proc.setUpdateFn(func(u SessionUpdate) {
+ called = true
+ })
+ proc.dispatchUpdate(SessionUpdate{Kind: "message"})
+ if !called {
+ t.Error("expected updateFn to be called")
+ }
+}
+
+func TestACPProcess_SetUpdateFn_Nil(t *testing.T) {
+ proc := &ACPProcess{}
+ proc.setUpdateFn(func(u SessionUpdate) {})
+ proc.setUpdateFn(nil)
+ // Should not panic
+ proc.dispatchUpdate(SessionUpdate{Kind: "x"})
+}
diff --git a/internal/providers/acp/terminal_test.go b/internal/providers/acp/terminal_test.go
new file mode 100644
index 00000000..61378863
--- /dev/null
+++ b/internal/providers/acp/terminal_test.go
@@ -0,0 +1,370 @@
+package acp
+
+import (
+ "context"
+ "regexp"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// --- cappedBuffer tests ---
+
+func TestCappedBuffer_BasicWrite(t *testing.T) {
+ cb := &cappedBuffer{max: 20}
+ n, err := cb.Write([]byte("hello"))
+ if err != nil {
+ t.Fatalf("Write error: %v", err)
+ }
+ if n != 5 {
+ t.Errorf("expected n=5, got %d", n)
+ }
+ if cb.String() != "hello" {
+ t.Errorf("expected 'hello', got %q", cb.String())
+ }
+}
+
+func TestCappedBuffer_ExactMax(t *testing.T) {
+ cb := &cappedBuffer{max: 5}
+ cb.Write([]byte("hello")) // exactly 5
+ if cb.String() != "hello" {
+ t.Errorf("expected 'hello', got %q", cb.String())
+ }
+}
+
+func TestCappedBuffer_SmallOverflow(t *testing.T) {
+ // buf has 4 bytes, new 3-byte write causes overflow=2
+ // overflow < buf.Len() → truncate from front: keep buf[2:] + new data
+ cb := &cappedBuffer{max: 5}
+ cb.Write([]byte("abcd")) // buf="abcd" (4 bytes)
+ cb.Write([]byte("xyz")) // overflow = 4+3-5 = 2 < 4 → keep buf[2:]="cd", append "xyz"
+ got := cb.String()
+ if len(got) > 5 {
+ t.Errorf("expected len ≤ 5, got %d: %q", len(got), got)
+ }
+ // Tail should end with the new data
+ if !strings.HasSuffix(got, "xyz") {
+ t.Errorf("expected suffix 'xyz', got %q", got)
+ }
+}
+
+func TestCappedBuffer_LargeOverflow(t *testing.T) {
+ // Write that is larger than max alone
+ cb := &cappedBuffer{max: 5}
+ cb.Write([]byte("hello world 123456789")) // 21 bytes, max 5
+ got := cb.String()
+ if len(got) > 5 {
+ t.Errorf("expected len ≤ 5, got %d: %q", len(got), got)
+ }
+ // Should contain the last 5 bytes of "hello world 123456789" = "56789"
+ if got != "56789" {
+ t.Errorf("expected '56789', got %q", got)
+ }
+}
+
+func TestCappedBuffer_OverflowExceedsExistingBuf(t *testing.T) {
+ // overflow >= buf.Len() branch: reset buf, truncate p to last max bytes
+ cb := &cappedBuffer{max: 3}
+ cb.Write([]byte("ab")) // buf="ab" (2)
+ cb.Write([]byte("xyz")) // overflow = 2+3-3 = 2 >= buf.Len(2) → reset buf, write "xyz"
+ got := cb.String()
+ if got != "xyz" {
+ t.Errorf("expected 'xyz', got %q", got)
+ }
+}
+
+func TestCappedBuffer_MultipleSmallWrites(t *testing.T) {
+ cb := &cappedBuffer{max: 10}
+ for i := 0; i < 5; i++ {
+ cb.Write([]byte("ab"))
+ }
+ got := cb.String()
+ if len(got) > 10 {
+ t.Errorf("expected len ≤ 10, got %d", len(got))
+ }
+}
+
+func TestCappedBuffer_ZeroMax(t *testing.T) {
+ cb := &cappedBuffer{max: 0}
+ // cappedBuffer with max=0 truncates all data: p becomes p[len(p)-0:] = []
+ // bytes.Buffer.Write([]) returns n=0
+ _, err := cb.Write([]byte("data"))
+ if err != nil {
+ t.Fatalf("Write error: %v", err)
+ }
+ if cb.String() != "" {
+ t.Errorf("expected empty buffer with max=0, got %q", cb.String())
+ }
+}
+
+func TestCappedBuffer_ConcurrentWrites(t *testing.T) {
+ cb := &cappedBuffer{max: 100}
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ cb.Write([]byte("data"))
+ }()
+ }
+ wg.Wait()
+ s := cb.String()
+ if len(s) > 100 {
+ t.Errorf("expected len ≤ 100 after concurrent writes, got %d", len(s))
+ }
+}
+
+// --- allowedTerminalBinaries ---
+
+func TestAllowedTerminalBinaries_Contains(t *testing.T) {
+ allowed := []string{
+ "sh", "bash", "zsh", "node", "python3", "go", "git",
+ "ls", "cat", "grep", "rg", "find", "curl", "docker",
+ "npm", "pip", "jq",
+ }
+ for _, bin := range allowed {
+ if !allowedTerminalBinaries[bin] {
+ t.Errorf("expected %q to be in allowed binaries", bin)
+ }
+ }
+}
+
+func TestAllowedTerminalBinaries_NotContains(t *testing.T) {
+ denied := []string{"rm", "dd", "mkfs", "fsck", "shutdown", "reboot", "nc", "netcat"}
+ for _, bin := range denied {
+ if allowedTerminalBinaries[bin] {
+ t.Errorf("expected %q to NOT be in allowed binaries", bin)
+ }
+ }
+}
+
+// --- terminalOutput / releaseTerminal / killTerminal via ToolBridge ---
+
+func makeRunningTerminal(t *testing.T, id string) *Terminal {
+ t.Helper()
+ term := &Terminal{
+ id: id,
+ output: &cappedBuffer{max: 1024},
+ exited: make(chan struct{}),
+ cancel: func() {},
+ }
+ return term
+}
+
+func makeExitedTerminal(t *testing.T, id string, code int) *Terminal {
+ t.Helper()
+ term := &Terminal{
+ id: id,
+ output: &cappedBuffer{max: 1024},
+ exited: make(chan struct{}),
+ exitCode: code,
+ cancel: func() {},
+ }
+ close(term.exited) // pre-exited
+ return term
+}
+
+func TestTerminalOutput_Running(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ term := makeRunningTerminal(t, "t1")
+ term.output.Write([]byte("some output"))
+ tb.terminals.Store("t1", term)
+
+ resp, err := tb.terminalOutput(TerminalOutputRequest{TerminalID: "t1"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.Output != "some output" {
+ t.Errorf("expected 'some output', got %q", resp.Output)
+ }
+ if resp.ExitStatus != nil {
+ t.Error("expected nil ExitStatus for running terminal")
+ }
+}
+
+func TestTerminalOutput_Exited(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ term := makeExitedTerminal(t, "t2", 42)
+ term.output.Write([]byte("done"))
+ tb.terminals.Store("t2", term)
+
+ resp, err := tb.terminalOutput(TerminalOutputRequest{TerminalID: "t2"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.ExitStatus == nil {
+ t.Fatal("expected non-nil ExitStatus for exited terminal")
+ }
+ if *resp.ExitStatus != 42 {
+ t.Errorf("expected exit code 42, got %d", *resp.ExitStatus)
+ }
+}
+
+func TestTerminalOutput_NotFound(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.terminalOutput(TerminalOutputRequest{TerminalID: "missing"})
+ if err == nil {
+ t.Error("expected error for missing terminal")
+ }
+}
+
+func TestReleaseTerminal_ExistingCancels(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ cancelled := false
+ var mu sync.Mutex
+ term := makeRunningTerminal(t, "t3")
+ term.cancel = func() {
+ mu.Lock()
+ cancelled = true
+ mu.Unlock()
+ }
+ tb.terminals.Store("t3", term)
+
+ resp, err := tb.releaseTerminal(ReleaseTerminalRequest{TerminalID: "t3"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp == nil {
+ t.Fatal("expected non-nil response")
+ }
+
+ mu.Lock()
+ wasCancelled := cancelled
+ mu.Unlock()
+
+ if !wasCancelled {
+ t.Error("expected cancel to be called on release")
+ }
+
+ // Terminal should be removed
+ if _, ok := tb.terminals.Load("t3"); ok {
+ t.Error("expected terminal to be removed after release")
+ }
+}
+
+func TestReleaseTerminal_NotFound(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ resp, err := tb.releaseTerminal(ReleaseTerminalRequest{TerminalID: "none"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp == nil {
+ t.Fatal("expected non-nil response even for missing terminal")
+ }
+}
+
+func TestKillTerminal_Cancels(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ killed := false
+ var mu sync.Mutex
+ term := makeRunningTerminal(t, "tk1")
+ term.cancel = func() {
+ mu.Lock()
+ killed = true
+ mu.Unlock()
+ }
+ tb.terminals.Store("tk1", term)
+
+ resp, err := tb.killTerminal(KillTerminalRequest{TerminalID: "tk1"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp == nil {
+ t.Fatal("expected non-nil response")
+ }
+
+ mu.Lock()
+ wasKilled := killed
+ mu.Unlock()
+
+ if !wasKilled {
+ t.Error("expected cancel to be called on kill")
+ }
+}
+
+func TestKillTerminal_NotFound(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.killTerminal(KillTerminalRequest{TerminalID: "missing"})
+ if err == nil {
+ t.Error("expected error for missing terminal")
+ }
+}
+
+func TestWaitForExit_Exited(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ term := makeExitedTerminal(t, "tw1", 0)
+ tb.terminals.Store("tw1", term)
+
+ resp, err := tb.waitForExit(context.Background(), WaitForTerminalExitRequest{TerminalID: "tw1"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.ExitStatus != 0 {
+ t.Errorf("expected exit 0, got %d", resp.ExitStatus)
+ }
+}
+
+func TestWaitForExit_ContextCancel(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ term := makeRunningTerminal(t, "tw2") // never exits
+ tb.terminals.Store("tw2", term)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ _, err := tb.waitForExit(ctx, WaitForTerminalExitRequest{TerminalID: "tw2"})
+ if err == nil {
+ t.Fatal("expected context cancellation error")
+ }
+}
+
+func TestWaitForExit_NotFound(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.waitForExit(context.Background(), WaitForTerminalExitRequest{TerminalID: "missing"})
+ if err == nil {
+ t.Error("expected error for missing terminal")
+ }
+}
+
+// --- createTerminal security: deny patterns and binary allowlist ---
+
+func TestCreateTerminal_AllowlistDenied(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.createTerminal(CreateTerminalRequest{Command: "rm", Args: []string{"-rf", "/"}})
+ if err == nil {
+ t.Fatal("expected error for disallowed binary")
+ }
+ if !strings.Contains(err.Error(), "not in allowed binary list") {
+ t.Errorf("expected allowlist error, got %q", err.Error())
+ }
+}
+
+func TestCreateTerminal_DenyPattern(t *testing.T) {
+ pat := regexp.MustCompile(`rm -rf`)
+ tb, _ := newTestBridge(t, WithDenyPatterns([]*regexp.Regexp{pat}))
+
+ // "bash" is allowed but the combined command matches deny pattern
+ _, err := tb.createTerminal(CreateTerminalRequest{
+ Command: "bash",
+ Args: []string{"-c", "rm -rf /tmp/test"},
+ })
+ if err == nil {
+ t.Fatal("expected deny pattern to block command")
+ }
+ if !strings.Contains(err.Error(), "denied by safety policy") {
+ t.Errorf("expected safety policy error, got %q", err.Error())
+ }
+}
+
+func TestCreateTerminal_InvalidCwd(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ // Path traversal in cwd should be denied
+ _, err := tb.createTerminal(CreateTerminalRequest{
+ Command: "ls",
+ Cwd: "../../etc",
+ })
+ if err == nil {
+ t.Fatal("expected error for cwd outside workspace")
+ }
+}
diff --git a/internal/providers/acp/tool_bridge_test.go b/internal/providers/acp/tool_bridge_test.go
new file mode 100644
index 00000000..7f54f74b
--- /dev/null
+++ b/internal/providers/acp/tool_bridge_test.go
@@ -0,0 +1,476 @@
+package acp
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// newTestBridge creates a ToolBridge with a real temp workspace.
+// The returned dir is the symlink-resolved path so comparisons on macOS
+// (where /var/folders is a symlink to /private/var/folders) don't fail.
+func newTestBridge(t *testing.T, opts ...ToolBridgeOption) (*ToolBridge, string) {
+ t.Helper()
+ raw := t.TempDir()
+ // Resolve symlinks so workspace in ToolBridge matches what EvalSymlinks returns
+ dir, err := filepath.EvalSymlinks(raw)
+ if err != nil {
+ dir = raw
+ }
+ tb := NewToolBridge(dir, opts...)
+ return tb, dir
+}
+
+// --- NewToolBridge / options ---
+
+func TestNewToolBridge_Defaults(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ if tb.workspace == "" {
+ t.Error("workspace should not be empty")
+ }
+ if tb.permMode != "approve-all" {
+ t.Errorf("expected default permMode 'approve-all', got %q", tb.permMode)
+ }
+ if tb.maxOutputBytes != 10*1024*1024 {
+ t.Errorf("expected 10MB maxOutputBytes, got %d", tb.maxOutputBytes)
+ }
+}
+
+func TestWithPermMode_Empty_KeepsDefault(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode(""))
+ if tb.permMode != "approve-all" {
+ t.Errorf("empty permMode should keep default, got %q", tb.permMode)
+ }
+}
+
+func TestWithPermMode_Set(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("deny-all"))
+ if tb.permMode != "deny-all" {
+ t.Errorf("expected deny-all, got %q", tb.permMode)
+ }
+}
+
+func TestWithDenyPatterns(t *testing.T) {
+ pat := regexp.MustCompile(`rm -rf`)
+ tb, _ := newTestBridge(t, WithDenyPatterns([]*regexp.Regexp{pat}))
+ if len(tb.denyPatterns) != 1 {
+ t.Errorf("expected 1 deny pattern, got %d", len(tb.denyPatterns))
+ }
+}
+
+// --- resolvePath boundary tests ---
+
+func TestResolvePath_WithinWorkspace(t *testing.T) {
+ tb, dir := newTestBridge(t)
+ // Create a file so EvalSymlinks succeeds
+ f := filepath.Join(dir, "file.txt")
+ os.WriteFile(f, []byte("x"), 0644)
+ got, err := tb.resolvePath("file.txt")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.HasPrefix(got, dir) {
+ t.Errorf("expected path within %q, got %q", dir, got)
+ }
+}
+
+func TestResolvePath_AbsoluteInsideWorkspace(t *testing.T) {
+ tb, dir := newTestBridge(t)
+ f := filepath.Join(dir, "abs.txt")
+ os.WriteFile(f, []byte("x"), 0644)
+ got, err := tb.resolvePath(f)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // Both got and f should resolve to the same real path
+ wantReal, _ := filepath.EvalSymlinks(f)
+ if wantReal == "" {
+ wantReal = f
+ }
+ if got != wantReal {
+ t.Errorf("expected %q, got %q", wantReal, got)
+ }
+}
+
+func TestResolvePath_Escape_PathTraversal(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.resolvePath("../../etc/passwd")
+ if err == nil {
+ t.Error("expected access denied for path traversal")
+ }
+ if !strings.Contains(err.Error(), "access denied") {
+ t.Errorf("expected 'access denied' in error, got %q", err.Error())
+ }
+}
+
+func TestResolvePath_AbsoluteOutsideWorkspace(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.resolvePath("/etc/passwd")
+ if err == nil {
+ t.Error("expected access denied for absolute path outside workspace")
+ }
+}
+
+func TestResolvePath_NonExistentFile_AllowedForWrites(t *testing.T) {
+ tb, dir := newTestBridge(t)
+ // File doesn't exist yet — resolvePath falls back to cleaned path (no EvalSymlinks).
+ // The result may use /private prefix on macOS; check it's within the resolved dir.
+ got, err := tb.resolvePath("newfile.txt")
+ if err != nil {
+ t.Fatalf("unexpected error for non-existent file: %v", err)
+ }
+ // Resolve both sides for comparison
+ gotReal, _ := filepath.EvalSymlinks(filepath.Dir(got))
+ if gotReal == "" {
+ gotReal = filepath.Dir(got)
+ }
+ if !strings.HasPrefix(gotReal, dir) && gotReal != dir {
+ t.Errorf("expected path within workspace %q, got dir %q", dir, gotReal)
+ }
+}
+
+// --- handlePermission ---
+
+func TestHandlePermission_ApproveAll(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("approve-all"))
+ resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "bash", Description: "run"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.Outcome != "approved" {
+ t.Errorf("expected 'approved', got %q", resp.Outcome)
+ }
+}
+
+func TestHandlePermission_DenyAll(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("deny-all"))
+ resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "any_tool"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.Outcome != "denied" {
+ t.Errorf("expected 'denied', got %q", resp.Outcome)
+ }
+}
+
+func TestHandlePermission_ApproveReads_ReadTool(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("approve-reads"))
+ cases := []string{"readFile", "glob_files", "search_code", "list_dir", "grep_search", "view_file"}
+ for _, name := range cases {
+ t.Run(name, func(t *testing.T) {
+ resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: name})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.Outcome != "approved" {
+ t.Errorf("expected approved for %q, got %q", name, resp.Outcome)
+ }
+ })
+ }
+}
+
+func TestHandlePermission_ApproveReads_WriteTool(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("approve-reads"))
+ resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "write_file"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.Outcome != "denied" {
+ t.Errorf("expected denied for write_file, got %q", resp.Outcome)
+ }
+}
+
+func TestHandlePermission_DefaultMode_Approves(t *testing.T) {
+ // permMode = "" defaults to "approve-all" behaviour (unknown → approve)
+ tb := &ToolBridge{permMode: "unknown-mode"}
+ resp, err := tb.handlePermission(RequestPermissionRequest{ToolName: "anything"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.Outcome != "approved" {
+ t.Errorf("expected approved, got %q", resp.Outcome)
+ }
+}
+
+// --- readFile / writeFile ---
+
+func TestReadFile_Success(t *testing.T) {
+ tb, dir := newTestBridge(t)
+ f := filepath.Join(dir, "read.txt")
+ os.WriteFile(f, []byte("hello content"), 0644)
+
+ resp, err := tb.readFile(ReadTextFileRequest{Path: "read.txt"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp.Content != "hello content" {
+ t.Errorf("expected 'hello content', got %q", resp.Content)
+ }
+}
+
+func TestReadFile_NotFound(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.readFile(ReadTextFileRequest{Path: "missing.txt"})
+ if err == nil {
+ t.Error("expected error for missing file")
+ }
+}
+
+func TestReadFile_PathEscape(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.readFile(ReadTextFileRequest{Path: "../../etc/passwd"})
+ if err == nil {
+ t.Error("expected path escape to be denied")
+ }
+}
+
+func TestWriteFile_Success(t *testing.T) {
+ tb, dir := newTestBridge(t)
+ resp, err := tb.writeFile(WriteTextFileRequest{Path: "out.txt", Content: "written"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resp == nil {
+ t.Fatal("expected non-nil response")
+ }
+ got, _ := os.ReadFile(filepath.Join(dir, "out.txt"))
+ if string(got) != "written" {
+ t.Errorf("expected 'written', got %q", string(got))
+ }
+}
+
+func TestWriteFile_CreatesSubdirs(t *testing.T) {
+ tb, dir := newTestBridge(t)
+ _, err := tb.writeFile(WriteTextFileRequest{Path: "sub/dir/file.txt", Content: "nested"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ got, _ := os.ReadFile(filepath.Join(dir, "sub", "dir", "file.txt"))
+ if string(got) != "nested" {
+ t.Errorf("expected 'nested', got %q", string(got))
+ }
+}
+
+func TestWriteFile_PathEscape(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.writeFile(WriteTextFileRequest{Path: "../../evil.txt", Content: "x"})
+ if err == nil {
+ t.Error("expected path escape to be denied")
+ }
+}
+
+// --- Handle dispatch ---
+
+func TestHandle_FsReadTextFile(t *testing.T) {
+ tb, dir := newTestBridge(t)
+ f := filepath.Join(dir, "test.txt")
+ os.WriteFile(f, []byte("file data"), 0644)
+
+ params, _ := json.Marshal(ReadTextFileRequest{Path: "test.txt"})
+ result, err := tb.Handle(context.Background(), "fs/readTextFile", params)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ resp := result.(*ReadTextFileResponse)
+ if resp.Content != "file data" {
+ t.Errorf("expected 'file data', got %q", resp.Content)
+ }
+}
+
+func TestHandle_FsReadTextFile_DenyAll(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("deny-all"))
+ params, _ := json.Marshal(ReadTextFileRequest{Path: "x.txt"})
+ _, err := tb.Handle(context.Background(), "fs/readTextFile", params)
+ if err == nil {
+ t.Error("expected deny-all to reject read")
+ }
+}
+
+func TestHandle_FsWriteTextFile(t *testing.T) {
+ tb, dir := newTestBridge(t)
+ params, _ := json.Marshal(WriteTextFileRequest{Path: "w.txt", Content: "hello"})
+ _, err := tb.Handle(context.Background(), "fs/writeTextFile", params)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ got, _ := os.ReadFile(filepath.Join(dir, "w.txt"))
+ if string(got) != "hello" {
+ t.Errorf("expected 'hello', got %q", string(got))
+ }
+}
+
+func TestHandle_FsWriteTextFile_DenyAll(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("deny-all"))
+ params, _ := json.Marshal(WriteTextFileRequest{Path: "x.txt", Content: "x"})
+ _, err := tb.Handle(context.Background(), "fs/writeTextFile", params)
+ if err == nil {
+ t.Error("expected deny-all to reject write")
+ }
+}
+
+func TestHandle_FsWriteTextFile_ApproveReads(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("approve-reads"))
+ params, _ := json.Marshal(WriteTextFileRequest{Path: "x.txt", Content: "x"})
+ _, err := tb.Handle(context.Background(), "fs/writeTextFile", params)
+ if err == nil {
+ t.Error("expected approve-reads to reject write")
+ }
+}
+
+func TestHandle_PermissionRequest(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ params, _ := json.Marshal(RequestPermissionRequest{ToolName: "bash"})
+ result, err := tb.Handle(context.Background(), "permission/request", params)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ resp := result.(*RequestPermissionResponse)
+ if resp.Outcome != "approved" {
+ t.Errorf("expected approved, got %q", resp.Outcome)
+ }
+}
+
+func TestHandle_UnknownMethod(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ _, err := tb.Handle(context.Background(), "unknown/method", json.RawMessage(`{}`))
+ if err == nil {
+ t.Error("expected error for unknown method")
+ }
+ if !strings.Contains(err.Error(), "unknown method") {
+ t.Errorf("expected 'unknown method' in error, got %q", err.Error())
+ }
+}
+
+func TestHandle_MalformedParams(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ methods := []string{
+ "fs/readTextFile", "fs/writeTextFile",
+ "terminal/output", "terminal/release",
+ "terminal/waitForExit", "terminal/kill",
+ "permission/request",
+ }
+ for _, m := range methods {
+ t.Run(m, func(t *testing.T) {
+ _, err := tb.Handle(context.Background(), m, json.RawMessage(`{invalid json`))
+ if err == nil {
+ t.Errorf("expected error for malformed params on %q", m)
+ }
+ })
+ }
+}
+
+func TestHandle_TerminalCreate_DenyAll(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("deny-all"))
+ params, _ := json.Marshal(CreateTerminalRequest{Command: "ls"})
+ _, err := tb.Handle(context.Background(), "terminal/create", params)
+ if err == nil {
+ t.Error("expected deny-all to reject terminal/create")
+ }
+}
+
+func TestHandle_TerminalCreate_ApproveReads(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("approve-reads"))
+ params, _ := json.Marshal(CreateTerminalRequest{Command: "ls"})
+ _, err := tb.Handle(context.Background(), "terminal/create", params)
+ if err == nil {
+ t.Error("expected approve-reads to reject terminal/create")
+ }
+}
+
+func TestHandle_TerminalOutput_NotFound(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ params, _ := json.Marshal(TerminalOutputRequest{TerminalID: "nonexistent"})
+ _, err := tb.Handle(context.Background(), "terminal/output", params)
+ if err == nil {
+ t.Error("expected error for nonexistent terminal")
+ }
+}
+
+func TestHandle_TerminalRelease_NotFound(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ params, _ := json.Marshal(ReleaseTerminalRequest{TerminalID: "nonexistent"})
+ result, err := tb.Handle(context.Background(), "terminal/release", params)
+ // releaseTerminal returns success even for nonexistent ID
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result == nil {
+ t.Error("expected non-nil result")
+ }
+}
+
+func TestHandle_TerminalKill_NotFound(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ params, _ := json.Marshal(KillTerminalRequest{TerminalID: "nonexistent"})
+ _, err := tb.Handle(context.Background(), "terminal/kill", params)
+ if err == nil {
+ t.Error("expected error for nonexistent terminal kill")
+ }
+}
+
+func TestHandle_TerminalKill_DenyAll(t *testing.T) {
+ tb, _ := newTestBridge(t, WithPermMode("deny-all"))
+ params, _ := json.Marshal(KillTerminalRequest{TerminalID: "t1"})
+ _, err := tb.Handle(context.Background(), "terminal/kill", params)
+ if err == nil {
+ t.Error("expected deny-all to reject terminal/kill")
+ }
+}
+
+func TestHandle_TerminalWaitForExit_NotFound(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ params, _ := json.Marshal(WaitForTerminalExitRequest{TerminalID: "nonexistent"})
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ _, err := tb.Handle(ctx, "terminal/waitForExit", params)
+ if err == nil {
+ t.Error("expected error for nonexistent terminal waitForExit")
+ }
+}
+
+// --- Close kills all active terminals ---
+
+func TestToolBridge_Close_KillsTerminals(t *testing.T) {
+ tb, _ := newTestBridge(t)
+
+ // Manually insert a fake terminal with a cancel function
+ cancelled := false
+ var mu sync.Mutex
+ fakeTerm := &Terminal{
+ id: "t1",
+ exited: make(chan struct{}),
+ cancel: func() {
+ mu.Lock()
+ cancelled = true
+ mu.Unlock()
+ },
+ }
+ tb.terminals.Store("t1", fakeTerm)
+
+ err := tb.Close()
+ if err != nil {
+ t.Fatalf("Close error: %v", err)
+ }
+
+ mu.Lock()
+ wasCancelled := cancelled
+ mu.Unlock()
+
+ if !wasCancelled {
+ t.Error("expected terminal cancel to be called on Close")
+ }
+}
+
+func TestToolBridge_Close_EmptyTerminals(t *testing.T) {
+ tb, _ := newTestBridge(t)
+ err := tb.Close()
+ if err != nil {
+ t.Fatalf("Close on empty bridge failed: %v", err)
+ }
+}
diff --git a/internal/providers/acp/types_test.go b/internal/providers/acp/types_test.go
new file mode 100644
index 00000000..7ec2543c
--- /dev/null
+++ b/internal/providers/acp/types_test.go
@@ -0,0 +1,262 @@
+package acp
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+// roundTrip marshals v to JSON then unmarshals into a new value of same type.
+// Returns the JSON bytes and the round-tripped value.
+func roundTrip[T any](t *testing.T, v T) ([]byte, T) {
+ t.Helper()
+ data, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("marshal error: %v", err)
+ }
+ var out T
+ if err := json.Unmarshal(data, &out); err != nil {
+ t.Fatalf("unmarshal error: %v", err)
+ }
+ return data, out
+}
+
+func TestInitializeRequest_RoundTrip(t *testing.T) {
+ req := InitializeRequest{
+ ClientInfo: ClientInfo{Name: "goclaw", Version: "1.0"},
+ Capabilities: ClientCaps{
+ Fs: &FsCaps{ReadTextFile: true, WriteTextFile: false},
+ Terminal: &TerminalCaps{Enabled: true},
+ },
+ }
+ _, got := roundTrip(t, req)
+ if got.ClientInfo.Name != "goclaw" {
+ t.Errorf("ClientInfo.Name: got %q", got.ClientInfo.Name)
+ }
+ if got.ClientInfo.Version != "1.0" {
+ t.Errorf("ClientInfo.Version: got %q", got.ClientInfo.Version)
+ }
+ if got.Capabilities.Fs == nil || !got.Capabilities.Fs.ReadTextFile {
+ t.Error("Capabilities.Fs.ReadTextFile should be true")
+ }
+ if got.Capabilities.Fs.WriteTextFile {
+ t.Error("Capabilities.Fs.WriteTextFile should be false")
+ }
+ if got.Capabilities.Terminal == nil || !got.Capabilities.Terminal.Enabled {
+ t.Error("Capabilities.Terminal.Enabled should be true")
+ }
+}
+
+func TestInitializeResponse_RoundTrip(t *testing.T) {
+ resp := InitializeResponse{
+ AgentInfo: AgentInfo{Name: "claude-code", Version: "2.0"},
+ Capabilities: AgentCaps{
+ LoadSession: true,
+ PromptCapabilities: &PromptCaps{
+ Audio: false,
+ Image: true,
+ EmbeddedContext: true,
+ },
+ SessionCapabilities: &SessionCaps{},
+ },
+ }
+ _, got := roundTrip(t, resp)
+ if got.AgentInfo.Name != "claude-code" {
+ t.Errorf("AgentInfo.Name: got %q", got.AgentInfo.Name)
+ }
+ if !got.Capabilities.LoadSession {
+ t.Error("LoadSession should be true")
+ }
+ if got.Capabilities.PromptCapabilities == nil {
+ t.Fatal("PromptCapabilities should not be nil")
+ }
+ if !got.Capabilities.PromptCapabilities.Image {
+ t.Error("PromptCapabilities.Image should be true")
+ }
+}
+
+func TestNewSessionResponse_RoundTrip(t *testing.T) {
+ resp := NewSessionResponse{SessionID: "sess-abc-123"}
+ _, got := roundTrip(t, resp)
+ if got.SessionID != "sess-abc-123" {
+ t.Errorf("SessionID: got %q", got.SessionID)
+ }
+}
+
+func TestPromptRequest_RoundTrip(t *testing.T) {
+ req := PromptRequest{
+ SessionID: "sess-1",
+ Content: []ContentBlock{
+ {Type: "text", Text: "hello"},
+ {Type: "image", Data: "base64data", MimeType: "image/png"},
+ },
+ }
+ _, got := roundTrip(t, req)
+ if got.SessionID != "sess-1" {
+ t.Errorf("SessionID: got %q", got.SessionID)
+ }
+ if len(got.Content) != 2 {
+ t.Fatalf("expected 2 content blocks, got %d", len(got.Content))
+ }
+ if got.Content[0].Type != "text" || got.Content[0].Text != "hello" {
+ t.Errorf("content[0]: got type=%q text=%q", got.Content[0].Type, got.Content[0].Text)
+ }
+ if got.Content[1].Type != "image" || got.Content[1].Data != "base64data" {
+ t.Errorf("content[1]: got type=%q data=%q", got.Content[1].Type, got.Content[1].Data)
+ }
+}
+
+func TestPromptResponse_RoundTrip(t *testing.T) {
+ resp := PromptResponse{StopReason: "endTurn"}
+ _, got := roundTrip(t, resp)
+ if got.StopReason != "endTurn" {
+ t.Errorf("StopReason: got %q", got.StopReason)
+ }
+}
+
+func TestCancelNotification_RoundTrip(t *testing.T) {
+ n := CancelNotification{SessionID: "sess-xyz"}
+ _, got := roundTrip(t, n)
+ if got.SessionID != "sess-xyz" {
+ t.Errorf("SessionID: got %q", got.SessionID)
+ }
+}
+
+func TestSessionUpdate_RoundTrip(t *testing.T) {
+ exitCode := 0
+ su := SessionUpdate{
+ Kind: "message",
+ StopReason: "endTurn",
+ Message: &MessageUpdate{
+ Role: "assistant",
+ Content: []ContentBlock{
+ {Type: "text", Text: "done"},
+ },
+ },
+ ToolCall: &ToolCallUpdate{
+ ID: "tc-1",
+ Name: "run_code",
+ Status: "completed",
+ Content: []ContentBlock{
+ {Type: "text", Text: "exit 0"},
+ },
+ },
+ }
+ _ = exitCode
+ _, got := roundTrip(t, su)
+ if got.Kind != "message" {
+ t.Errorf("Kind: got %q", got.Kind)
+ }
+ if got.StopReason != "endTurn" {
+ t.Errorf("StopReason: got %q", got.StopReason)
+ }
+ if got.Message == nil || got.Message.Role != "assistant" {
+ t.Error("Message.Role should be assistant")
+ }
+ if got.ToolCall == nil || got.ToolCall.ID != "tc-1" {
+ t.Error("ToolCall.ID should be tc-1")
+ }
+}
+
+func TestContentBlock_OmitEmpty(t *testing.T) {
+ // Text block: data and mimeType should be omitted
+ cb := ContentBlock{Type: "text", Text: "hello"}
+ data, err := json.Marshal(cb)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]any
+ json.Unmarshal(data, &m)
+ if _, hasData := m["data"]; hasData {
+ t.Error("data field should be omitted for text block")
+ }
+ if _, hasMime := m["mimeType"]; hasMime {
+ t.Error("mimeType field should be omitted for text block")
+ }
+}
+
+func TestTerminalTypes_RoundTrip(t *testing.T) {
+ create := CreateTerminalRequest{Command: "bash", Args: []string{"-c", "echo hi"}, Cwd: "/tmp"}
+ _, gotCreate := roundTrip(t, create)
+ if gotCreate.Command != "bash" || len(gotCreate.Args) != 2 || gotCreate.Cwd != "/tmp" {
+ t.Errorf("CreateTerminalRequest round-trip failed: %+v", gotCreate)
+ }
+
+ exitCode := 42
+ output := TerminalOutputResponse{Output: "hello", ExitStatus: &exitCode}
+ _, gotOutput := roundTrip(t, output)
+ if gotOutput.Output != "hello" {
+ t.Errorf("Output: got %q", gotOutput.Output)
+ }
+ if gotOutput.ExitStatus == nil || *gotOutput.ExitStatus != 42 {
+ t.Errorf("ExitStatus: expected 42, got %v", gotOutput.ExitStatus)
+ }
+
+ waitResp := WaitForTerminalExitResponse{ExitStatus: 1}
+ _, gotWait := roundTrip(t, waitResp)
+ if gotWait.ExitStatus != 1 {
+ t.Errorf("WaitForTerminalExitResponse.ExitStatus: got %d", gotWait.ExitStatus)
+ }
+}
+
+func TestPermissionTypes_RoundTrip(t *testing.T) {
+ req := RequestPermissionRequest{ToolName: "bash", Description: "run a script"}
+ _, got := roundTrip(t, req)
+ if got.ToolName != "bash" || got.Description != "run a script" {
+ t.Errorf("RequestPermissionRequest round-trip: %+v", got)
+ }
+
+ resp := RequestPermissionResponse{Outcome: "approved"}
+ _, gotResp := roundTrip(t, resp)
+ if gotResp.Outcome != "approved" {
+ t.Errorf("RequestPermissionResponse.Outcome: got %q", gotResp.Outcome)
+ }
+}
+
+func TestFsTypes_RoundTrip(t *testing.T) {
+ read := ReadTextFileRequest{Path: "/workspace/file.go"}
+ _, gotRead := roundTrip(t, read)
+ if gotRead.Path != "/workspace/file.go" {
+ t.Errorf("ReadTextFileRequest.Path: got %q", gotRead.Path)
+ }
+
+ readResp := ReadTextFileResponse{Content: "package main"}
+ _, gotReadResp := roundTrip(t, readResp)
+ if gotReadResp.Content != "package main" {
+ t.Errorf("ReadTextFileResponse.Content: got %q", gotReadResp.Content)
+ }
+
+ write := WriteTextFileRequest{Path: "/workspace/out.txt", Content: "data"}
+ _, gotWrite := roundTrip(t, write)
+ if gotWrite.Path != "/workspace/out.txt" || gotWrite.Content != "data" {
+ t.Errorf("WriteTextFileRequest round-trip: %+v", gotWrite)
+ }
+}
+
+func TestClientCaps_NilOmitted(t *testing.T) {
+ caps := ClientCaps{} // both Fs and Terminal nil
+ data, err := json.Marshal(caps)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]any
+ json.Unmarshal(data, &m)
+ if _, hasFs := m["fs"]; hasFs {
+ t.Error("fs should be omitted when nil")
+ }
+ if _, hasTerm := m["terminal"]; hasTerm {
+ t.Error("terminal should be omitted when nil")
+ }
+}
+
+func TestTerminalOutputResponse_NilExitStatus(t *testing.T) {
+ resp := TerminalOutputResponse{Output: "running...", ExitStatus: nil}
+ data, err := json.Marshal(resp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m map[string]any
+ json.Unmarshal(data, &m)
+ if _, hasExit := m["exitStatus"]; hasExit {
+ t.Error("exitStatus should be omitted when nil")
+ }
+}