Files
goclaw/internal/channels/feishu/larkclient_messaging_extra_test.go
jason1804 7e51666357 fix(feishu): parse bot/v3/info top-level bot object for mention detection (#1189)
The legacy /open-apis/bot/v3/info endpoint returns the bot object at the
top level of the response, not inside "data" like newer Lark APIs. The
probe always failed to extract open_id, leaving botOpenID empty, which
made every group mention count as a bot mention — the bot replied to
messages mentioning other users even with requireMention enabled.

- Read top-level "bot" first, fall back to data.bot defensively
- Retry the startup probe with backoff to survive transient failures
- Fix test fixtures to match the real API response shape

Fixes #895
2026-06-21 03:34:07 +07:00

404 lines
12 KiB
Go

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) {
// Real /open-apis/bot/v3/info responses put "bot" at the TOP level,
// not inside "data" (legacy API shape).
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","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_DataWrappedFallback(t *testing.T) {
// Defensive fallback for deployments that wrap the bot object in "data".
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok","data":{"bot":{"open_id":"ou_bot_456","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_456" {
t.Errorf("open_id: got %q, want ou_bot_456", openID)
}
}
func TestGetBotInfo_MissingBotObject(t *testing.T) {
srv := newSimpleMockServer(t, `{"code":0,"msg":"ok"}`)
c := NewLarkClient("app", "secret", srv.URL)
_, err := c.GetBotInfo(context.Background())
if err == nil {
t.Fatal("expected error when response has no bot object")
}
}
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")
}
}