Files
2026-06-02 23:14:41 +08:00

819 lines
19 KiB
Go

package weibo
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/gorilla/websocket"
)
func TestNew_RequiredFields(t *testing.T) {
tests := []struct {
name string
opts map[string]any
wantErr bool
}{
{"missing both", map[string]any{}, true},
{"missing app_secret", map[string]any{"app_id": "id"}, true},
{"missing app_id", map[string]any{"app_secret": "secret"}, true},
{"valid", map[string]any{"app_id": "id", "app_secret": "secret"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p, err := New(tt.opts)
if tt.wantErr {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if p.Name() != "weibo" {
t.Errorf("name = %q, want %q", p.Name(), "weibo")
}
})
}
}
func TestNew_CustomName(t *testing.T) {
p, err := New(map[string]any{
"app_id": "id",
"app_secret": "secret",
"name": "my-weibo",
})
if err != nil {
t.Fatal(err)
}
if p.Name() != "my-weibo" {
t.Errorf("name = %q, want %q", p.Name(), "my-weibo")
}
}
func TestNew_CustomEndpoints(t *testing.T) {
p, err := New(map[string]any{
"app_id": "id",
"app_secret": "secret",
"token_endpoint": "https://custom.example.com/token",
"ws_endpoint": "ws://custom.example.com/ws",
})
if err != nil {
t.Fatal(err)
}
plat := p.(*Platform)
if plat.tokenEndpoint != "https://custom.example.com/token" {
t.Errorf("tokenEndpoint = %q", plat.tokenEndpoint)
}
if plat.wsEndpoint != "ws://custom.example.com/ws" {
t.Errorf("wsEndpoint = %q", plat.wsEndpoint)
}
}
func TestSplitText(t *testing.T) {
tests := []struct {
name string
text string
limit int
chunks int
}{
{"short", "hello", 100, 1},
{"exact", "abcde", 5, 1},
{"split", "abcdefgh", 3, 3},
{"empty", "", 10, 1},
{"unicode", "你好世界测试", 3, 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := splitText(tt.text, tt.limit)
if len(result) != tt.chunks {
t.Errorf("splitText(%q, %d) = %d chunks, want %d", tt.text, tt.limit, len(result), tt.chunks)
}
joined := strings.Join(result, "")
if joined != tt.text {
t.Errorf("joined = %q, want %q", joined, tt.text)
}
})
}
}
func TestIsDuplicate(t *testing.T) {
p := &Platform{seen: make(map[string]struct{})}
if p.isDuplicate("msg1") {
t.Error("first occurrence should not be duplicate")
}
if !p.isDuplicate("msg1") {
t.Error("second occurrence should be duplicate")
}
if p.isDuplicate("msg2") {
t.Error("different message should not be duplicate")
}
}
func TestIsDuplicate_Prune(t *testing.T) {
p := &Platform{seen: make(map[string]struct{})}
for i := 0; i < maxSeenMessages+100; i++ {
p.isDuplicate(strings.Repeat("x", 10) + string(rune(i)))
}
if len(p.seen) > maxSeenMessages {
t.Errorf("seen map should be pruned, got %d entries", len(p.seen))
}
}
func TestHandleInbound(t *testing.T) {
p := &Platform{
name: "weibo",
allowFrom: "*",
seen: make(map[string]struct{}),
}
var received *core.Message
var mu sync.Mutex
p.handler = func(_ core.Platform, msg *core.Message) {
mu.Lock()
received = msg
mu.Unlock()
}
payload := messagePayload{
MessageID: "test-123",
FromUserID: "user1",
Text: "hello world",
Timestamp: 1234567890,
}
raw, _ := json.Marshal(payload)
p.handleInbound(raw)
mu.Lock()
defer mu.Unlock()
if received == nil {
t.Fatal("handler not called")
}
if received.SessionKey != "weibo:user1:user1" {
t.Errorf("sessionKey = %q", received.SessionKey)
}
if received.Content != "hello world" {
t.Errorf("content = %q", received.Content)
}
if received.UserID != "user1" {
t.Errorf("userID = %q", received.UserID)
}
if received.MessageID != "test-123" {
t.Errorf("messageID = %q", received.MessageID)
}
}
func TestHandleInbound_AllowList(t *testing.T) {
p := &Platform{
name: "weibo",
allowFrom: "user2,user3",
seen: make(map[string]struct{}),
}
called := false
p.handler = func(_ core.Platform, _ *core.Message) {
called = true
}
payload := messagePayload{
MessageID: "blocked-1",
FromUserID: "user1",
Text: "hello",
}
raw, _ := json.Marshal(payload)
p.handleInbound(raw)
if called {
t.Error("handler should not be called for unauthorized user")
}
}
func TestHandleInbound_EmptyText(t *testing.T) {
p := &Platform{
name: "weibo",
allowFrom: "*",
seen: make(map[string]struct{}),
}
called := false
p.handler = func(_ core.Platform, _ *core.Message) {
called = true
}
payload := messagePayload{
MessageID: "empty-1",
FromUserID: "user1",
Text: "",
}
raw, _ := json.Marshal(payload)
p.handleInbound(raw)
if called {
t.Error("handler should not be called for empty text without attachments")
}
}
func TestHandleInbound_WithImage(t *testing.T) {
p := &Platform{
name: "weibo",
allowFrom: "*",
seen: make(map[string]struct{}),
}
var received *core.Message
var mu sync.Mutex
p.handler = func(_ core.Platform, msg *core.Message) {
mu.Lock()
received = msg
mu.Unlock()
}
imgData := []byte("fake-png-data")
b64 := base64.StdEncoding.EncodeToString(imgData)
payload := messagePayload{
MessageID: "img-1",
FromUserID: "user1",
Text: "check this image",
Input: []messageInputItem{{
Type: "message",
Role: "user",
Content: []contentPart{{
Type: "input_image",
FileName: "photo.png",
Source: &inputSource{Type: "base64", MediaType: "image/png", Data: b64},
}},
}},
}
raw, _ := json.Marshal(payload)
p.handleInbound(raw)
mu.Lock()
defer mu.Unlock()
if received == nil {
t.Fatal("handler not called")
}
if received.Content != "check this image" {
t.Errorf("content = %q", received.Content)
}
if len(received.Images) != 1 {
t.Fatalf("images = %d, want 1", len(received.Images))
}
if received.Images[0].MimeType != "image/png" {
t.Errorf("image mime = %q", received.Images[0].MimeType)
}
if received.Images[0].FileName != "photo.png" {
t.Errorf("image filename = %q", received.Images[0].FileName)
}
if string(received.Images[0].Data) != "fake-png-data" {
t.Errorf("image data mismatch")
}
}
func TestHandleInbound_WithFile(t *testing.T) {
p := &Platform{
name: "weibo",
allowFrom: "*",
seen: make(map[string]struct{}),
}
var received *core.Message
var mu sync.Mutex
p.handler = func(_ core.Platform, msg *core.Message) {
mu.Lock()
received = msg
mu.Unlock()
}
fileData := []byte("hello world pdf content")
b64 := base64.StdEncoding.EncodeToString(fileData)
payload := messagePayload{
MessageID: "file-1",
FromUserID: "user1",
Text: "",
Input: []messageInputItem{{
Type: "message",
Role: "user",
Content: []contentPart{
{Type: "input_text", Text: "here is my file"},
{
Type: "input_file",
FileName: "doc.pdf",
Source: &inputSource{Type: "base64", MediaType: "application/pdf", Data: b64},
},
},
}},
}
raw, _ := json.Marshal(payload)
p.handleInbound(raw)
mu.Lock()
defer mu.Unlock()
if received == nil {
t.Fatal("handler not called")
}
if received.Content != "here is my file" {
t.Errorf("content = %q, want %q", received.Content, "here is my file")
}
if len(received.Files) != 1 {
t.Fatalf("files = %d, want 1", len(received.Files))
}
if received.Files[0].MimeType != "application/pdf" {
t.Errorf("file mime = %q", received.Files[0].MimeType)
}
if received.Files[0].FileName != "doc.pdf" {
t.Errorf("file name = %q", received.Files[0].FileName)
}
}
func TestHandleInbound_ImageOnlyNoText(t *testing.T) {
p := &Platform{
name: "weibo",
allowFrom: "*",
seen: make(map[string]struct{}),
}
var received *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
received = msg
}
imgData := []byte("image-bytes")
b64 := base64.StdEncoding.EncodeToString(imgData)
payload := messagePayload{
MessageID: "imgonly-1",
FromUserID: "user1",
Text: "",
Input: []messageInputItem{{
Type: "message",
Role: "user",
Content: []contentPart{{
Type: "input_image",
Source: &inputSource{Type: "base64", MediaType: "image/jpeg", Data: b64},
}},
}},
}
raw, _ := json.Marshal(payload)
p.handleInbound(raw)
if received == nil {
t.Fatal("handler should be called for image-only message")
}
if len(received.Images) != 1 {
t.Errorf("images = %d, want 1", len(received.Images))
}
}
func TestHandleInbound_UnsupportedImageMime(t *testing.T) {
p := &Platform{
name: "weibo",
allowFrom: "*",
seen: make(map[string]struct{}),
}
var received *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
received = msg
}
b64 := base64.StdEncoding.EncodeToString([]byte("bmp-data"))
payload := messagePayload{
MessageID: "bmp-1",
FromUserID: "user1",
Text: "a bmp image",
Input: []messageInputItem{{
Type: "message",
Role: "user",
Content: []contentPart{{
Type: "input_image",
Source: &inputSource{Type: "base64", MediaType: "image/bmp", Data: b64},
}},
}},
}
raw, _ := json.Marshal(payload)
p.handleInbound(raw)
if received == nil {
t.Fatal("handler should be called for text content")
}
if len(received.Images) != 0 {
t.Errorf("unsupported image should be filtered, got %d images", len(received.Images))
}
}
func TestHandleInbound_InputTextOverridesPayloadText(t *testing.T) {
p := &Platform{
name: "weibo",
allowFrom: "*",
seen: make(map[string]struct{}),
}
var received *core.Message
p.handler = func(_ core.Platform, msg *core.Message) {
received = msg
}
payload := messagePayload{
MessageID: "override-1",
FromUserID: "user1",
Text: "payload text",
Input: []messageInputItem{{
Type: "message",
Role: "user",
Content: []contentPart{
{Type: "input_text", Text: "input part 1"},
{Type: "input_text", Text: "input part 2"},
},
}},
}
raw, _ := json.Marshal(payload)
p.handleInbound(raw)
if received == nil {
t.Fatal("handler not called")
}
if received.Content != "input part 1\ninput part 2" {
t.Errorf("content = %q, want joined input_text", received.Content)
}
}
func TestNormalizeInboundInput_SkipsNonUserRole(t *testing.T) {
payload := messagePayload{
FromUserID: "user1",
Text: "fallback",
Input: []messageInputItem{
{
Type: "message",
Role: "assistant",
Content: []contentPart{
{Type: "input_text", Text: "should be ignored"},
},
},
},
}
text, images, files := normalizeInboundInput(payload)
if text != "fallback" {
t.Errorf("text = %q, want fallback", text)
}
if len(images) != 0 || len(files) != 0 {
t.Error("should have no attachments from non-user role")
}
}
func TestRefreshToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("method = %s, want POST", r.Method)
}
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"token": "test-token-abc",
"expire_in": 3600,
"uid": 12345,
},
})
}))
defer ts.Close()
p := &Platform{
appID: "test-app",
appSecret: "test-secret",
tokenEndpoint: ts.URL,
seen: make(map[string]struct{}),
}
tok, err := p.refreshToken()
if err != nil {
t.Fatal(err)
}
if tok != "test-token-abc" {
t.Errorf("token = %q, want %q", tok, "test-token-abc")
}
if p.uid != "12345" {
t.Errorf("uid = %q, want %q", p.uid, "12345")
}
}
func TestSendMessage(t *testing.T) {
upgrader := websocket.Upgrader{}
gotMsg := make(chan map[string]any, 1)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
for {
_, msg, err := c.ReadMessage()
if err != nil {
return
}
var m map[string]any
json.Unmarshal(msg, &m)
gotMsg <- m
}
}))
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http")
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatal(err)
}
defer ws.Close()
p := &Platform{
name: "weibo",
ws: ws,
seen: make(map[string]struct{}),
}
rctx := replyContext{fromUserID: "user1", sessionKey: "weibo:user1:user1"}
err = p.sendMessage(rctx, "short message")
if err != nil {
t.Fatal(err)
}
select {
case m := <-gotMsg:
if m["type"] != "send_message" {
t.Errorf("type = %v", m["type"])
}
payload := m["payload"].(map[string]any)
if payload["toUserId"] != "user1" {
t.Errorf("toUserId = %v", payload["toUserId"])
}
if payload["text"] != "short message" {
t.Errorf("text = %v", payload["text"])
}
if payload["done"] != true {
t.Errorf("done = %v", payload["done"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for message")
}
}
func newWSTestPlatform(t *testing.T) (*Platform, chan map[string]any) {
t.Helper()
upgrader := websocket.Upgrader{}
gotMsg := make(chan map[string]any, 5)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
for {
_, msg, err := c.ReadMessage()
if err != nil {
return
}
var m map[string]any
json.Unmarshal(msg, &m)
gotMsg <- m
}
}))
t.Cleanup(ts.Close)
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http")
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ws.Close() })
p := &Platform{name: "weibo", ws: ws, seen: make(map[string]struct{})}
return p, gotMsg
}
func TestSendImage(t *testing.T) {
p, gotMsg := newWSTestPlatform(t)
rctx := replyContext{fromUserID: "user1", sessionKey: "weibo:user1:user1"}
imgData := []byte("fake-image-bytes")
err := p.SendImage(context.Background(), rctx, core.ImageAttachment{
MimeType: "image/png",
Data: imgData,
FileName: "screenshot.png",
})
if err != nil {
t.Fatal(err)
}
select {
case m := <-gotMsg:
if m["type"] != "send_message" {
t.Errorf("type = %v", m["type"])
}
payload := m["payload"].(map[string]any)
if payload["toUserId"] != "user1" {
t.Errorf("toUserId = %v", payload["toUserId"])
}
if payload["done"] != true {
t.Errorf("done = %v", payload["done"])
}
input, ok := payload["input"].([]any)
if !ok || len(input) == 0 {
t.Fatal("input missing or empty")
}
item := input[0].(map[string]any)
if item["role"] != "assistant" {
t.Errorf("role = %v", item["role"])
}
content := item["content"].([]any)
part := content[0].(map[string]any)
if part["type"] != "input_image" {
t.Errorf("part type = %v", part["type"])
}
if part["filename"] != "screenshot.png" {
t.Errorf("filename = %v", part["filename"])
}
src := part["source"].(map[string]any)
if src["media_type"] != "image/png" {
t.Errorf("media_type = %v", src["media_type"])
}
decoded, _ := base64.StdEncoding.DecodeString(src["data"].(string))
if string(decoded) != string(imgData) {
t.Error("image data mismatch after round-trip")
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestSendFile(t *testing.T) {
p, gotMsg := newWSTestPlatform(t)
rctx := replyContext{fromUserID: "user1", sessionKey: "weibo:user1:user1"}
fileData := []byte("pdf-content-here")
err := p.SendFile(context.Background(), rctx, core.FileAttachment{
MimeType: "application/pdf",
Data: fileData,
FileName: "report.pdf",
})
if err != nil {
t.Fatal(err)
}
select {
case m := <-gotMsg:
payload := m["payload"].(map[string]any)
input := payload["input"].([]any)
item := input[0].(map[string]any)
content := item["content"].([]any)
part := content[0].(map[string]any)
if part["type"] != "input_file" {
t.Errorf("part type = %v", part["type"])
}
if part["filename"] != "report.pdf" {
t.Errorf("filename = %v", part["filename"])
}
src := part["source"].(map[string]any)
if src["media_type"] != "application/pdf" {
t.Errorf("media_type = %v", src["media_type"])
}
decoded, _ := base64.StdEncoding.DecodeString(src["data"].(string))
if string(decoded) != string(fileData) {
t.Error("file data mismatch")
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestSendImage_NotConnected(t *testing.T) {
p := &Platform{name: "weibo", seen: make(map[string]struct{})}
rctx := replyContext{fromUserID: "u1"}
err := p.SendImage(context.Background(), rctx, core.ImageAttachment{Data: []byte("x")})
if err == nil {
t.Error("expected error when not connected")
}
if !strings.Contains(err.Error(), "not connected") {
t.Errorf("error = %q, want 'not connected'", err.Error())
}
}
func TestSendFile_InvalidContext(t *testing.T) {
p := &Platform{name: "weibo", seen: make(map[string]struct{})}
err := p.SendFile(context.Background(), "invalid", core.FileAttachment{Data: []byte("x")})
if err == nil {
t.Error("expected error for invalid context")
}
}
func TestInterfaceCompliance(t *testing.T) {
var _ core.ImageSender = (*Platform)(nil)
var _ core.FileSender = (*Platform)(nil)
}
// TestWriteWS_ConcurrentSendsSerialized verifies that writeWS serializes
// concurrent callers as gorilla/websocket requires (one writer at a time).
// Without the wsMu fix, parallel WriteJSON calls race on the underlying
// Conn.writer field (caught by go test -race) and may interleave frames.
func TestWriteWS_ConcurrentSendsSerialized(t *testing.T) {
upgrader := websocket.Upgrader{}
const n = 50
gotMsg := make(chan map[string]any, n*2)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
for {
_, msg, err := c.ReadMessage()
if err != nil {
return
}
var m map[string]any
if err := json.Unmarshal(msg, &m); err != nil {
gotMsg <- map[string]any{"_parse_error": err.Error(), "_raw": string(msg)}
continue
}
gotMsg <- m
}
}))
defer ts.Close()
wsURL := "ws" + strings.TrimPrefix(ts.URL, "http")
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatal(err)
}
defer ws.Close()
p := &Platform{name: "weibo", ws: ws, seen: make(map[string]struct{})}
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
rctx := replyContext{fromUserID: fmt.Sprintf("u%d", i)}
if err := p.sendMessage(rctx, fmt.Sprintf("m%d", i)); err != nil {
errs <- err
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Errorf("concurrent sendMessage: %v", err)
}
seen := map[string]bool{}
deadline := time.After(3 * time.Second)
for len(seen) < n {
select {
case m := <-gotMsg:
if pe, ok := m["_parse_error"]; ok {
t.Fatalf("server got malformed JSON frame (concurrent write interleaved): %v raw=%q", pe, m["_raw"])
}
payload, ok := m["payload"].(map[string]any)
if !ok {
t.Fatalf("frame missing payload: %v", m)
}
to, _ := payload["toUserId"].(string)
if to == "" {
t.Fatalf("frame missing toUserId: %v", payload)
}
if seen[to] {
t.Fatalf("duplicate frame for %s", to)
}
seen[to] = true
case <-deadline:
t.Fatalf("only got %d of %d messages within 3s", len(seen), n)
}
}
if len(seen) != n {
t.Fatalf("got %d unique frames, want %d", len(seen), n)
}
}