mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
test(acp,feishu): wave C greenfield coverage push
- internal/providers/acp 0.0% → 80.0% (7 test files, ~2560 LOC)
- helpers, types round-trip, jsonrpc framing + adversarial fuzz,
session, terminal/sandbox, tool_bridge (3 permission modes),
ProcessPool lifecycle
- internal/channels/feishu 20.6% → 63.9% (15 test files)
- bot parse/policy, factory, larkws proto + WS lifecycle,
larkclient HTTP error paths, larkevents AES-CBC decrypt +
tamper detection, media send/receive, lifecycle
Zero source modifications. Race clean (-race -count=3).
Dual build verified (standard + sqliteonly).
This commit is contained in:
1 parent
f2c43c3849
commit
32c6bc1451
22 files changed
+5858
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, "<at id=ou_abc123></at>") {
|
||||
t.Errorf("missing first mention in: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<at id=ou_def456></at>") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user