1458 lines
38 KiB
Go
1458 lines
38 KiB
Go
package wpsxiezuo
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/md5"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// ============================================================================
|
|
// Test helpers
|
|
// ============================================================================
|
|
|
|
// encryptEventForTest builds a valid encrypted event frame with proper
|
|
// signature, suitable for handleEvent end-to-end testing.
|
|
func encryptEventForTest(appID, appSecret, topic, operation string, payload any) wpsEventFrame {
|
|
nonce := "testnonce1234567" // 16+ bytes
|
|
|
|
// Serialize payload
|
|
plain, _ := json.Marshal(payload)
|
|
|
|
// Derive key & IV
|
|
hash := md5.Sum([]byte(appSecret))
|
|
key := []byte(hex.EncodeToString(hash[:]))
|
|
iv := []byte(nonce[:16])
|
|
|
|
// PKCS7 pad
|
|
padLen := aes.BlockSize - len(plain)%aes.BlockSize
|
|
padded := make([]byte, len(plain)+padLen)
|
|
copy(padded, plain)
|
|
for i := len(plain); i < len(padded); i++ {
|
|
padded[i] = byte(padLen)
|
|
}
|
|
|
|
// AES-CBC encrypt
|
|
block, _ := aes.NewCipher(key)
|
|
ciphertext := make([]byte, len(padded))
|
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, padded)
|
|
|
|
encryptedData := base64.StdEncoding.EncodeToString(ciphertext)
|
|
timestamp := time.Now().Unix()
|
|
|
|
// Sign: "access_key:topic:nonce:timestamp:encrypted_data"
|
|
sigContent := fmt.Sprintf("%s:%s:%s:%d:%s", appID, topic, nonce, timestamp, encryptedData)
|
|
mac := hmac.New(sha256.New, []byte(appSecret))
|
|
mac.Write([]byte(sigContent))
|
|
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
sig = strings.TrimRight(sig, "=")
|
|
|
|
return wpsEventFrame{
|
|
Topic: topic,
|
|
Operation: operation,
|
|
Time: timestamp,
|
|
Nonce: nonce,
|
|
Signature: sig,
|
|
EncryptedData: encryptedData,
|
|
AccessKey: appID,
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// New / Factory
|
|
// ============================================================================
|
|
|
|
func TestNew_MissingAppID(t *testing.T) {
|
|
_, err := New(map[string]any{"app_secret": "s"})
|
|
if err == nil {
|
|
t.Fatal("expected error when app_id is missing")
|
|
}
|
|
if !strings.Contains(err.Error(), "app_id") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNew_MissingAppSecret(t *testing.T) {
|
|
_, err := New(map[string]any{"app_id": "id"})
|
|
if err == nil {
|
|
t.Fatal("expected error when app_secret is missing")
|
|
}
|
|
if !strings.Contains(err.Error(), "app_secret") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNew_Valid(t *testing.T) {
|
|
p, err := New(map[string]any{
|
|
"app_id": "test-id",
|
|
"app_secret": "test-secret",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if p.Name() != "wps-xiezuo" {
|
|
t.Fatalf("expected name wps-xiezuo, got %s", p.Name())
|
|
}
|
|
}
|
|
|
|
func TestNew_CustomBaseURL(t *testing.T) {
|
|
p, err := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": "https://custom.example.com/",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
plat := p.(*Platform)
|
|
if plat.baseURL != "https://custom.example.com" {
|
|
t.Fatalf("expected trimmed base_url, got %q", plat.baseURL)
|
|
}
|
|
}
|
|
|
|
func TestNew_CleanReply(t *testing.T) {
|
|
p, err := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"clean_reply": true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
plat := p.(*Platform)
|
|
if !plat.cleanReply {
|
|
t.Fatal("expected clean_reply=true")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Platform interface compliance
|
|
// ============================================================================
|
|
|
|
func TestPlatformImplementsInterfaces(t *testing.T) {
|
|
var _ core.Platform = (*Platform)(nil)
|
|
var _ core.ReplyContextReconstructor = (*Platform)(nil)
|
|
var _ core.TypingIndicator = (*Platform)(nil)
|
|
var _ core.TypingIndicatorDone = (*Platform)(nil)
|
|
}
|
|
|
|
// ============================================================================
|
|
// ReconstructReplyCtx
|
|
// ============================================================================
|
|
|
|
func TestReconstructReplyCtx_Valid(t *testing.T) {
|
|
p := &Platform{}
|
|
rctx, err := p.ReconstructReplyCtx("wps-xiezuo:comp123:chat456")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
rc, ok := rctx.(replyContext)
|
|
if !ok {
|
|
t.Fatal("expected replyContext type")
|
|
}
|
|
if rc.ChatID != "chat456" {
|
|
t.Fatalf("expected chat456, got %s", rc.ChatID)
|
|
}
|
|
if rc.CompanyID != "comp123" {
|
|
t.Fatalf("expected comp123, got %s", rc.CompanyID)
|
|
}
|
|
}
|
|
|
|
func TestReconstructReplyCtx_P2PWithSenderKeepsActualChatID(t *testing.T) {
|
|
p := &Platform{}
|
|
rctx, err := p.ReconstructReplyCtx("wps-xiezuo:comp123:chat456:user789")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
rc, ok := rctx.(replyContext)
|
|
if !ok {
|
|
t.Fatal("expected replyContext type")
|
|
}
|
|
if rc.ChatID != "chat456" {
|
|
t.Fatalf("expected chat456, got %s", rc.ChatID)
|
|
}
|
|
if rc.SenderID != "user789" {
|
|
t.Fatalf("expected user789, got %s", rc.SenderID)
|
|
}
|
|
if rc.CompanyID != "comp123" {
|
|
t.Fatalf("expected comp123, got %s", rc.CompanyID)
|
|
}
|
|
}
|
|
|
|
func TestReconstructReplyCtx_InvalidPrefix(t *testing.T) {
|
|
p := &Platform{}
|
|
_, err := p.ReconstructReplyCtx("feishu:comp:chat")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid prefix")
|
|
}
|
|
}
|
|
|
|
func TestReconstructReplyCtx_TooFewParts(t *testing.T) {
|
|
p := &Platform{}
|
|
_, err := p.ReconstructReplyCtx("wps-xiezuo:onlyone")
|
|
if err == nil {
|
|
t.Fatal("expected error for too few parts")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Text extraction
|
|
// ============================================================================
|
|
|
|
func TestExtractText_PlainString(t *testing.T) {
|
|
raw := json.RawMessage(`"hello world"`)
|
|
got := extractText(raw)
|
|
if got != "hello world" {
|
|
t.Fatalf("expected 'hello world', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractText_TextType(t *testing.T) {
|
|
raw := json.RawMessage(`{"type":"text","content":"hello"}`)
|
|
got := extractText(raw)
|
|
if got != "hello" {
|
|
t.Fatalf("expected 'hello', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractText_TextTypeNestedDict(t *testing.T) {
|
|
raw := json.RawMessage(`{"type":"text","content":{"content":"nested hello"}}`)
|
|
got := extractText(raw)
|
|
if got != "nested hello" {
|
|
t.Fatalf("expected 'nested hello', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractText_RichText(t *testing.T) {
|
|
raw := json.RawMessage(`{"type":"rich_text","content":[{"text":"hello"},{"text":"world"}]}`)
|
|
got := extractText(raw)
|
|
if got != "hello world" {
|
|
t.Fatalf("expected 'hello world', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractText_FallbackContent(t *testing.T) {
|
|
raw := json.RawMessage(`{"content":"fallback text"}`)
|
|
got := extractText(raw)
|
|
if got != "fallback text" {
|
|
t.Fatalf("expected 'fallback text', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractText_Empty(t *testing.T) {
|
|
got := extractText(nil)
|
|
if got != "" {
|
|
t.Fatalf("expected empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// isP2P
|
|
// ============================================================================
|
|
|
|
func TestIsP2P(t *testing.T) {
|
|
tests := []struct {
|
|
chatType string
|
|
want bool
|
|
}{
|
|
{"p2p", true},
|
|
{"single", true},
|
|
{"direct", true},
|
|
{"group", false},
|
|
{"", false},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := isP2P(tt.chatType); got != tt.want {
|
|
t.Errorf("isP2P(%q) = %v, want %v", tt.chatType, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Clean reply content
|
|
// ============================================================================
|
|
|
|
func TestCleanReplyContent(t *testing.T) {
|
|
input := "normal line\n💭 thinking\n🔧 tool call\n🧾 output\nanother line"
|
|
got := cleanReplyContent(input)
|
|
want := "normal line\nanother line"
|
|
if got != want {
|
|
t.Fatalf("expected %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
func TestCleanReplyContent_AllFiltered(t *testing.T) {
|
|
input := "💭 think\n🔧 tool"
|
|
got := cleanReplyContent(input)
|
|
if got != input {
|
|
t.Fatalf("expected original %q, got %q", input, got)
|
|
}
|
|
}
|
|
|
|
func TestCleanReplyContent_Empty(t *testing.T) {
|
|
got := cleanReplyContent("")
|
|
if got != "" {
|
|
t.Fatalf("expected empty, got %q", got)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// AES-256-CBC decryption (round-trip test)
|
|
// ============================================================================
|
|
|
|
func TestDecryptEventData_RoundTrip(t *testing.T) {
|
|
appSecret := "test-secret-key-for-encryption"
|
|
|
|
hash := md5.Sum([]byte(appSecret))
|
|
key := []byte(hex.EncodeToString(hash[:]))
|
|
|
|
nonce := "abcdefghijklmnop"
|
|
iv := []byte(nonce[:16])
|
|
|
|
plaintext := []byte(`{"chat_id":"c1","message_id":"m1","sender":{"sender_id":"u1","name":"Test"},"content":"hello"}`)
|
|
|
|
padLen := aes.BlockSize - len(plaintext)%aes.BlockSize
|
|
padded := make([]byte, len(plaintext)+padLen)
|
|
copy(padded, plaintext)
|
|
for i := len(plaintext); i < len(padded); i++ {
|
|
padded[i] = byte(padLen)
|
|
}
|
|
|
|
block, _ := aes.NewCipher(key)
|
|
ciphertext := make([]byte, len(padded))
|
|
mode := cipher.NewCBCEncrypter(block, iv)
|
|
mode.CryptBlocks(ciphertext, padded)
|
|
|
|
encryptedData := base64.StdEncoding.EncodeToString(ciphertext)
|
|
|
|
accessKey := "AK_TEST"
|
|
timestamp := int64(1234567890)
|
|
sigContent := fmt.Sprintf("%s:%s:%s:%d:%s", accessKey, "kso.app_chat.message", nonce, timestamp, encryptedData)
|
|
mac := hmac.New(sha256.New, []byte(appSecret))
|
|
mac.Write([]byte(sigContent))
|
|
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
expectedSig = strings.TrimRight(expectedSig, "=")
|
|
|
|
event := wpsEventFrame{
|
|
Topic: "kso.app_chat.message",
|
|
Operation: "create",
|
|
Time: timestamp,
|
|
Nonce: nonce,
|
|
Signature: expectedSig,
|
|
EncryptedData: encryptedData,
|
|
AccessKey: accessKey,
|
|
}
|
|
|
|
p := &Platform{appSecret: appSecret, appID: accessKey}
|
|
|
|
if !p.verifyEventSignature(event) {
|
|
t.Fatal("signature verification failed")
|
|
}
|
|
|
|
decrypted, err := p.decryptEventData(event.Nonce, event.EncryptedData)
|
|
if err != nil {
|
|
t.Fatalf("decrypt failed: %v", err)
|
|
}
|
|
|
|
if string(decrypted) != string(plaintext) {
|
|
t.Fatalf("decrypted mismatch:\ngot: %s\nwant: %s", decrypted, plaintext)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// PKCS7 unpadding
|
|
// ============================================================================
|
|
|
|
func TestPKCS7Unpad_Valid(t *testing.T) {
|
|
data := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x03, 0x03, 0x03}
|
|
got, err := pkcs7Unpad(data)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != 5 {
|
|
t.Fatalf("expected length 5, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestPKCS7Unpad_InvalidPadding(t *testing.T) {
|
|
data := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09}
|
|
_, err := pkcs7Unpad(data)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid padding")
|
|
}
|
|
}
|
|
|
|
func TestPKCS7Unpad_EmptyData(t *testing.T) {
|
|
_, err := pkcs7Unpad([]byte{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty data")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Signature verification
|
|
// ============================================================================
|
|
|
|
func TestVerifyEventSignature_Valid(t *testing.T) {
|
|
appSecret := "my-secret"
|
|
accessKey := "AK123"
|
|
topic := "kso.app_chat.message"
|
|
nonce := "nonce1234567890"
|
|
timestamp := int64(1700000000)
|
|
encryptedData := "dGVzdA=="
|
|
|
|
content := fmt.Sprintf("%s:%s:%s:%d:%s", accessKey, topic, nonce, timestamp, encryptedData)
|
|
mac := hmac.New(sha256.New, []byte(appSecret))
|
|
mac.Write([]byte(content))
|
|
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
sig = strings.TrimRight(sig, "=")
|
|
|
|
p := &Platform{appSecret: appSecret, appID: accessKey}
|
|
event := wpsEventFrame{
|
|
Topic: topic,
|
|
Nonce: nonce,
|
|
Time: timestamp,
|
|
Signature: sig,
|
|
EncryptedData: encryptedData,
|
|
AccessKey: accessKey,
|
|
}
|
|
|
|
if !p.verifyEventSignature(event) {
|
|
t.Fatal("signature should be valid")
|
|
}
|
|
}
|
|
|
|
func TestVerifyEventSignature_Invalid(t *testing.T) {
|
|
p := &Platform{appSecret: "secret", appID: "id"}
|
|
event := wpsEventFrame{
|
|
Topic: "t",
|
|
Nonce: "n",
|
|
Time: 1,
|
|
Signature: "badsig",
|
|
AccessKey: "ak",
|
|
EncryptedData: "dA==",
|
|
}
|
|
|
|
if p.verifyEventSignature(event) {
|
|
t.Fatal("signature should be invalid")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Raw message dispatch
|
|
// ============================================================================
|
|
|
|
func TestHandleRawMessage_ACK(t *testing.T) {
|
|
p := &Platform{}
|
|
raw := []byte(`{"type":"ack","nonce":"abc","code":200}`)
|
|
p.handleRawMessage(context.Background(), raw)
|
|
}
|
|
|
|
func TestHandleRawMessage_GoAway(t *testing.T) {
|
|
p := &Platform{}
|
|
raw := []byte(`{"type":"goaway","reason":"server_shutdown","message":"bye"}`)
|
|
p.handleRawMessage(context.Background(), raw)
|
|
if p.stopped {
|
|
t.Fatal("should not stop for server_shutdown")
|
|
}
|
|
}
|
|
|
|
func TestHandleRawMessage_GoAwayReplaced(t *testing.T) {
|
|
p := &Platform{}
|
|
raw := []byte(`{"type":"goaway","reason":"connection_replaced","message":"bye"}`)
|
|
p.handleRawMessage(context.Background(), raw)
|
|
if !p.stopped {
|
|
t.Fatal("should stop for connection_replaced")
|
|
}
|
|
}
|
|
|
|
func TestHandleRawMessage_InvalidJSON(t *testing.T) {
|
|
p := &Platform{}
|
|
raw := []byte(`not json at all`)
|
|
p.handleRawMessage(context.Background(), raw)
|
|
}
|
|
|
|
// ============================================================================
|
|
// KSO-1 signing
|
|
// ============================================================================
|
|
|
|
func TestSignWSHeader(t *testing.T) {
|
|
p := &Platform{appID: "test-app", appSecret: "test-secret"}
|
|
header, err := p.signWSHeader()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if header.Get("X-Kso-Date") == "" {
|
|
t.Fatal("X-Kso-Date header missing")
|
|
}
|
|
auth := header.Get("X-Kso-Authorization")
|
|
if !strings.HasPrefix(auth, "KSO-1 test-app:") {
|
|
t.Fatalf("unexpected auth header: %q", auth)
|
|
}
|
|
if header.Get("X-Ack-Mode") != "required" {
|
|
t.Fatal("X-Ack-Mode should be required")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Handle chat message (unit)
|
|
// ============================================================================
|
|
|
|
func TestHandleChatMessage_P2P(t *testing.T) {
|
|
ch := make(chan *core.Message, 1)
|
|
p := &Platform{
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
ch <- msg
|
|
},
|
|
dedup: core.MessageDedup{},
|
|
}
|
|
|
|
msgData := wpsMessageData{
|
|
Chat: wpsChatInfo{
|
|
ID: "group1",
|
|
Type: "p2p",
|
|
},
|
|
CompanyID: "comp1",
|
|
Message: wpsMessageInfo{
|
|
ID: "msg1",
|
|
Content: json.RawMessage(`{"type":"text","content":"hello"}`),
|
|
},
|
|
}
|
|
msgData.Sender.ID = "user1"
|
|
|
|
plain, _ := json.Marshal(msgData)
|
|
p.handleChatMessage(plain)
|
|
|
|
select {
|
|
case received := <-ch:
|
|
if received.SessionKey != "wps-xiezuo:comp1:group1:user1" {
|
|
t.Fatalf("expected session key wps-xiezuo:comp1:group1:user1, got %s", received.SessionKey)
|
|
}
|
|
if received.Content != "hello" {
|
|
t.Fatalf("expected content 'hello', got %q", received.Content)
|
|
}
|
|
rc, ok := received.ReplyCtx.(replyContext)
|
|
if !ok {
|
|
t.Fatal("expected replyContext")
|
|
}
|
|
if rc.ChatID != "group1" {
|
|
t.Fatalf("expected reply chat id group1, got %s", rc.ChatID)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected message to be delivered")
|
|
}
|
|
}
|
|
|
|
func TestHandleChatMessage_Group(t *testing.T) {
|
|
ch := make(chan *core.Message, 1)
|
|
p := &Platform{
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
ch <- msg
|
|
},
|
|
dedup: core.MessageDedup{},
|
|
}
|
|
|
|
msgData := wpsMessageData{
|
|
Chat: wpsChatInfo{
|
|
ID: "group1",
|
|
Type: "group",
|
|
},
|
|
CompanyID: "comp1",
|
|
Message: wpsMessageInfo{
|
|
ID: "msg2",
|
|
Content: json.RawMessage(`"world"`),
|
|
},
|
|
}
|
|
msgData.Sender.ID = "user1"
|
|
|
|
plain, _ := json.Marshal(msgData)
|
|
p.handleChatMessage(plain)
|
|
|
|
select {
|
|
case received := <-ch:
|
|
if received.SessionKey != "wps-xiezuo:comp1:group1" {
|
|
t.Fatalf("expected session key wps-xiezuo:comp1:group1, got %s", received.SessionKey)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected message to be delivered")
|
|
}
|
|
}
|
|
|
|
func TestHandleChatMessage_Duplicate(t *testing.T) {
|
|
ch := make(chan *core.Message, 2)
|
|
p := &Platform{
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
ch <- msg
|
|
},
|
|
dedup: core.MessageDedup{},
|
|
}
|
|
|
|
msgData := wpsMessageData{
|
|
Chat: wpsChatInfo{
|
|
ID: "g1",
|
|
Type: "group",
|
|
},
|
|
CompanyID: "c1",
|
|
Message: wpsMessageInfo{
|
|
ID: "dup1",
|
|
Content: json.RawMessage(`"hi"`),
|
|
},
|
|
}
|
|
msgData.Sender.ID = "u1"
|
|
|
|
plain, _ := json.Marshal(msgData)
|
|
p.handleChatMessage(plain)
|
|
p.handleChatMessage(plain)
|
|
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timeout waiting for first message")
|
|
}
|
|
|
|
select {
|
|
case <-ch:
|
|
t.Fatal("expected duplicate to be filtered")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func TestHandleChatMessage_EmptyContent(t *testing.T) {
|
|
called := 0
|
|
p := &Platform{
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
called++
|
|
},
|
|
dedup: core.MessageDedup{},
|
|
}
|
|
|
|
msgData := wpsMessageData{
|
|
Chat: wpsChatInfo{
|
|
ID: "g1",
|
|
Type: "group",
|
|
},
|
|
CompanyID: "c1",
|
|
Message: wpsMessageInfo{
|
|
ID: "msg3",
|
|
Content: json.RawMessage(`""`),
|
|
},
|
|
}
|
|
msgData.Sender.ID = "u1"
|
|
|
|
plain, _ := json.Marshal(msgData)
|
|
p.handleChatMessage(plain)
|
|
|
|
if called != 0 {
|
|
t.Fatalf("expected 0 calls for empty content, got %d", called)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Handle chat message recall
|
|
// ============================================================================
|
|
|
|
func TestHandleChatMessageRecall(t *testing.T) {
|
|
ch := make(chan *core.Message, 1)
|
|
p := &Platform{
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
ch <- msg
|
|
},
|
|
}
|
|
|
|
recallData := struct {
|
|
ChatID string `json:"chat_id"`
|
|
ID string `json:"id"`
|
|
CompanyID string `json:"company_id"`
|
|
Operator struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
} `json:"operator"`
|
|
}{
|
|
ChatID: "g1",
|
|
ID: "msg-recall",
|
|
CompanyID: "c1",
|
|
}
|
|
recallData.Operator.ID = "u1"
|
|
recallData.Operator.Type = "user"
|
|
|
|
plain, _ := json.Marshal(recallData)
|
|
p.handleChatMessageRecall(plain)
|
|
|
|
select {
|
|
case received := <-ch:
|
|
if !received.Recalled {
|
|
t.Fatal("expected Recalled=true")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("expected recall message to be delivered")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Stop
|
|
// ============================================================================
|
|
|
|
func TestStop_Idempotent(t *testing.T) {
|
|
p := &Platform{}
|
|
if err := p.Stop(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if err := p.Stop(); err != nil {
|
|
t.Fatalf("unexpected error on second stop: %v", err)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// handleEvent — full chain: verify signature → decrypt → dispatch
|
|
// ============================================================================
|
|
|
|
func TestHandleEvent_FullChain_ChatMessage(t *testing.T) {
|
|
appID := "AK_FULLCHAIN"
|
|
appSecret := "fullchain-secret"
|
|
|
|
ch := make(chan *core.Message, 1)
|
|
p := &Platform{
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
ch <- msg
|
|
},
|
|
dedup: core.MessageDedup{},
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"chat": map[string]any{"id": "chat_fc", "type": "group"},
|
|
"company_id": "comp_fc",
|
|
"message": map[string]any{"id": "msg_fc", "type": "text", "content": map[string]string{"type": "text", "content": "full chain works"}},
|
|
"sender": map[string]any{"id": "u_fc", "type": "user"},
|
|
}
|
|
|
|
event := encryptEventForTest(appID, appSecret, "kso.app_chat.message", "create", payload)
|
|
p.handleEvent(event)
|
|
|
|
select {
|
|
case msg := <-ch:
|
|
if msg.SessionKey != "wps-xiezuo:comp_fc:chat_fc" {
|
|
t.Fatalf("expected session key wps-xiezuo:comp_fc:chat_fc, got %s", msg.SessionKey)
|
|
}
|
|
if msg.Content != "full chain works" {
|
|
t.Fatalf("expected 'full chain works', got %q", msg.Content)
|
|
}
|
|
if msg.UserName != "u_fc" {
|
|
t.Fatalf("expected UserName=u_fc, got %q", msg.UserName)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for message")
|
|
}
|
|
}
|
|
|
|
func TestHandleEvent_FullChain_Recall(t *testing.T) {
|
|
appID := "AK_RECALL"
|
|
appSecret := "recall-secret"
|
|
|
|
ch := make(chan *core.Message, 1)
|
|
p := &Platform{
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
ch <- msg
|
|
},
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"chat_id": "chat_recall",
|
|
"company_id": "comp_r",
|
|
"id": "msg_recall_chain",
|
|
"operator": map[string]string{"id": "u_recall", "type": "user"},
|
|
}
|
|
|
|
event := encryptEventForTest(appID, appSecret, "kso.app_chat.message.recall", "create", payload)
|
|
p.handleEvent(event)
|
|
|
|
select {
|
|
case msg := <-ch:
|
|
if !msg.Recalled {
|
|
t.Fatal("expected Recalled=true")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out")
|
|
}
|
|
}
|
|
|
|
func TestHandleEvent_BadSignature(t *testing.T) {
|
|
called := false
|
|
p := &Platform{
|
|
appID: "bad",
|
|
appSecret: "sig",
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
called = true
|
|
},
|
|
dedup: core.MessageDedup{},
|
|
}
|
|
|
|
event := wpsEventFrame{
|
|
Topic: "kso.app_chat.message",
|
|
Operation: "create",
|
|
Nonce: "nonce1234567890",
|
|
Signature: "INVALID_SIGNATURE",
|
|
EncryptedData: "dGVzdA==",
|
|
AccessKey: "bad",
|
|
Time: 1,
|
|
}
|
|
|
|
p.handleEvent(event)
|
|
if called {
|
|
t.Fatal("handler should not be called for invalid signature")
|
|
}
|
|
}
|
|
|
|
func TestHandleEvent_DoesNotLogDecryptedPayload(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
|
defer slog.SetDefault(prev)
|
|
|
|
appID := "AK_LOG"
|
|
appSecret := "log-secret"
|
|
p := &Platform{
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
handler: func(_ core.Platform, msg *core.Message) {},
|
|
dedup: core.MessageDedup{},
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"chat": map[string]any{"id": "chat_log", "type": "group"},
|
|
"company_id": "comp_log",
|
|
"message": map[string]any{"id": "msg_log", "type": "text", "content": map[string]string{"type": "text", "content": "SECRET_LOG_PAYLOAD"}},
|
|
"sender": map[string]any{"id": "u_log", "type": "user"},
|
|
}
|
|
|
|
event := encryptEventForTest(appID, appSecret, "kso.app_chat.message", "create", payload)
|
|
p.handleEvent(event)
|
|
|
|
if strings.Contains(buf.String(), "SECRET_LOG_PAYLOAD") {
|
|
t.Fatalf("decrypted payload should not be logged, got logs: %s", buf.String())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// allowFrom filtering
|
|
// ============================================================================
|
|
|
|
func TestHandleChatMessage_AllowFrom(t *testing.T) {
|
|
ch := make(chan *core.Message, 1)
|
|
p := &Platform{
|
|
appID: "id",
|
|
appSecret: "secret",
|
|
allowFrom: "allowed_user",
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
ch <- msg
|
|
},
|
|
dedup: core.MessageDedup{},
|
|
}
|
|
|
|
// authorized user
|
|
msgData := wpsMessageData{
|
|
Chat: wpsChatInfo{
|
|
ID: "g1",
|
|
Type: "group",
|
|
},
|
|
CompanyID: "c1",
|
|
Message: wpsMessageInfo{
|
|
ID: "m_allow1",
|
|
Content: json.RawMessage(`"hi"`),
|
|
},
|
|
}
|
|
msgData.Sender.ID = "allowed_user"
|
|
plain, _ := json.Marshal(msgData)
|
|
p.handleChatMessage(plain)
|
|
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("authorized user should pass")
|
|
}
|
|
|
|
// unauthorized user
|
|
msgData2 := wpsMessageData{
|
|
Chat: wpsChatInfo{
|
|
ID: "g1",
|
|
Type: "group",
|
|
},
|
|
CompanyID: "c1",
|
|
Message: wpsMessageInfo{
|
|
ID: "m_allow2",
|
|
Content: json.RawMessage(`"hi"`),
|
|
},
|
|
}
|
|
msgData2.Sender.ID = "blocked_user"
|
|
plain2, _ := json.Marshal(msgData2)
|
|
p.handleChatMessage(plain2)
|
|
|
|
select {
|
|
case <-ch:
|
|
t.Fatal("unauthorized user should be filtered")
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// sendWPSMessage via httptest server
|
|
// ============================================================================
|
|
|
|
func TestSendWPSMessage_Success(t *testing.T) {
|
|
var gotBody []byte
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/oauth2/token" {
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "test-token-123", ExpiresIn: 7200})
|
|
return
|
|
}
|
|
if r.URL.Path == "/v7/messages/create" {
|
|
gotBody, _ = io.ReadAll(r.Body)
|
|
if r.Header.Get("Authorization") != "Bearer test-token-123" {
|
|
t.Errorf("expected Bearer token, got %s", r.Header.Get("Authorization"))
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{"code": "0"})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
err := p.Reply(context.Background(), replyContext{ChatID: "chat_abc"}, "hello **world**")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if len(gotBody) == 0 {
|
|
t.Fatal("expected request body")
|
|
}
|
|
var req sendMessageRequest
|
|
if err := json.Unmarshal(gotBody, &req); err != nil {
|
|
t.Fatalf("parse request: %v", err)
|
|
}
|
|
if req.Receiver.ReceiverID != "chat_abc" {
|
|
t.Fatalf("expected receiver_id=chat_abc, got %s", req.Receiver.ReceiverID)
|
|
}
|
|
if req.Content.Text.Content != "hello **world**" {
|
|
t.Fatalf("unexpected content: %q", req.Content.Text.Content)
|
|
}
|
|
if req.Content.Text.Type != "markdown" {
|
|
t.Fatalf("expected markdown type, got %s", req.Content.Text.Type)
|
|
}
|
|
}
|
|
|
|
func TestSendWPSMessage_CleanReply(t *testing.T) {
|
|
var gotBody []byte
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/oauth2/token" {
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "tok", ExpiresIn: 7200})
|
|
return
|
|
}
|
|
if r.URL.Path == "/v7/messages/create" {
|
|
gotBody, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
"clean_reply": true,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
err := p.Reply(context.Background(), replyContext{ChatID: "c1"}, "ok\n💭 think\n🔧 tool")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
var req sendMessageRequest
|
|
json.Unmarshal(gotBody, &req)
|
|
if req.Content.Text.Content != "ok" {
|
|
t.Fatalf("expected cleaned content 'ok', got %q", req.Content.Text.Content)
|
|
}
|
|
}
|
|
|
|
func TestSendWPSMessage_EmptyContent(t *testing.T) {
|
|
plat, _ := New(map[string]any{"app_id": "id", "app_secret": "secret"})
|
|
p := plat.(*Platform)
|
|
err := p.Reply(context.Background(), replyContext{ChatID: "c1"}, "")
|
|
if err != nil {
|
|
t.Fatalf("expected nil error for empty content, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendWPSMessage_ApiError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/oauth2/token" {
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "tok", ExpiresIn: 7200})
|
|
return
|
|
}
|
|
if r.URL.Path == "/v7/messages/create" {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
w.Write([]byte(`{"code":"403"}`))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
err := p.Reply(context.Background(), replyContext{ChatID: "c1"}, "hi")
|
|
if err == nil {
|
|
t.Fatal("expected error for 403 response")
|
|
}
|
|
if !strings.Contains(err.Error(), "403") {
|
|
t.Fatalf("expected 403 in error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// getToken via httptest server
|
|
// ============================================================================
|
|
|
|
func TestGetToken_PrimaryEndpoint(t *testing.T) {
|
|
var hitPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hitPath = r.URL.Path
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "primary-tok", ExpiresIn: 3600})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
tok, err := p.getToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if tok != "primary-tok" {
|
|
t.Fatalf("expected primary-tok, got %s", tok)
|
|
}
|
|
if hitPath != "/oauth2/token" {
|
|
t.Fatalf("expected /oauth2/token, got %s", hitPath)
|
|
}
|
|
}
|
|
|
|
func TestGetToken_FallbackEndpoint(t *testing.T) {
|
|
var paths []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
paths = append(paths, r.URL.Path)
|
|
if r.URL.Path == "/oauth2/token" {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "fallback-tok", ExpiresIn: 7200})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
tok, err := p.getToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if tok != "fallback-tok" {
|
|
t.Fatalf("expected fallback-tok, got %s", tok)
|
|
}
|
|
if len(paths) != 2 || paths[0] != "/oauth2/token" || paths[1] != "/openapi/oauth2/token" {
|
|
t.Fatalf("expected fallback path sequence, got %v", paths)
|
|
}
|
|
}
|
|
|
|
func TestGetToken_Cached(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "cached-tok", ExpiresIn: 7200})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
tok, err := p.getToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if tok != "cached-tok" {
|
|
t.Fatalf("expected cached-tok, got %s", tok)
|
|
}
|
|
|
|
tok2, err := p.getToken(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if tok2 != "cached-tok" {
|
|
t.Fatalf("expected cached-tok, got %s", tok2)
|
|
}
|
|
|
|
if calls.Load() != 1 {
|
|
t.Fatalf("expected 1 token HTTP call, got %d", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestGetToken_AllEndpointsFail(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
_, err := p.getToken(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error when all endpoints fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "all token endpoints failed") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Reaction API via httptest server
|
|
// ============================================================================
|
|
|
|
func TestAddReaction(t *testing.T) {
|
|
var gotPath string
|
|
var gotBody []byte
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/oauth2/token" {
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "tok", ExpiresIn: 7200})
|
|
return
|
|
}
|
|
gotPath = r.URL.Path
|
|
gotBody, _ = io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
err := p.addReaction(context.Background(), replyContext{
|
|
ChatID: "chat_r",
|
|
MessageID: "msg_r",
|
|
}, "emoji_busy")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
expectedPath := "/v7/chats/chat_r/messages/msg_r/reactions/create"
|
|
if gotPath != expectedPath {
|
|
t.Fatalf("expected path %s, got %s", expectedPath, gotPath)
|
|
}
|
|
|
|
var req reactionRequest
|
|
json.Unmarshal(gotBody, &req)
|
|
if req.ReactionType != "emoji_busy" {
|
|
t.Fatalf("expected emoji_busy, got %s", req.ReactionType)
|
|
}
|
|
}
|
|
|
|
func TestDeleteReaction(t *testing.T) {
|
|
var gotPath string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/oauth2/token" {
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "tok", ExpiresIn: 7200})
|
|
return
|
|
}
|
|
gotPath = r.URL.Path
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
err := p.deleteReaction(context.Background(), replyContext{
|
|
ChatID: "c_del",
|
|
MessageID: "m_del",
|
|
}, "emoji_busy")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
expectedPath := "/v7/chats/c_del/messages/m_del/reactions/delete"
|
|
if gotPath != expectedPath {
|
|
t.Fatalf("expected path %s, got %s", expectedPath, gotPath)
|
|
}
|
|
}
|
|
|
|
func TestAddReaction_Error(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/oauth2/token" {
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "tok", ExpiresIn: 7200})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
err := p.addReaction(context.Background(), replyContext{
|
|
ChatID: "c",
|
|
MessageID: "m",
|
|
}, "emoji_busy")
|
|
if err == nil {
|
|
t.Fatal("expected error for 429")
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// TypingIndicator via httptest
|
|
// ============================================================================
|
|
|
|
func TestStartTyping(t *testing.T) {
|
|
var addCalled atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/oauth2/token" {
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "tok", ExpiresIn: 7200})
|
|
return
|
|
}
|
|
if strings.Contains(r.URL.Path, "reactions/create") {
|
|
addCalled.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
stop := p.StartTyping(context.Background(), replyContext{
|
|
ChatID: "c",
|
|
MessageID: "m",
|
|
})
|
|
stop() // no-op
|
|
|
|
if addCalled.Load() != 1 {
|
|
t.Fatalf("expected 1 add reaction call, got %d", addCalled.Load())
|
|
}
|
|
}
|
|
|
|
func TestStartTyping_NoMessageID(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
stop := p.StartTyping(context.Background(), replyContext{ChatID: "c"})
|
|
stop()
|
|
|
|
if calls.Load() != 0 {
|
|
t.Fatalf("expected no HTTP calls without message_id, got %d", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestAddDoneReaction(t *testing.T) {
|
|
var delCalled atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/oauth2/token" {
|
|
json.NewEncoder(w).Encode(tokenResponse{AccessToken: "tok", ExpiresIn: 7200})
|
|
return
|
|
}
|
|
if strings.Contains(r.URL.Path, "reactions/delete") {
|
|
delCalled.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
p.AddDoneReaction(replyContext{
|
|
ChatID: "c",
|
|
MessageID: "m",
|
|
})
|
|
|
|
if delCalled.Load() != 1 {
|
|
t.Fatalf("expected 1 delete reaction call, got %d", delCalled.Load())
|
|
}
|
|
}
|
|
|
|
func TestAddDoneReaction_NoMessageID(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
plat, _ := New(map[string]any{
|
|
"app_id": "id",
|
|
"app_secret": "secret",
|
|
"base_url": srv.URL,
|
|
})
|
|
p := plat.(*Platform)
|
|
|
|
p.AddDoneReaction(replyContext{ChatID: "c"})
|
|
|
|
if calls.Load() != 0 {
|
|
t.Fatalf("expected no HTTP calls without message_id, got %d", calls.Load())
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// WebSocket integration: mock server → handleRawMessage
|
|
// ============================================================================
|
|
|
|
func TestWebSocketIntegration_ReceiveEvent(t *testing.T) {
|
|
appID := "AK_WS"
|
|
appSecret := "ws-secret"
|
|
|
|
var up atomic.Int32
|
|
upgrader := websocket.Upgrader{}
|
|
wsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
up.Add(1)
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Build encrypted event
|
|
payload := map[string]any{
|
|
"chat": map[string]any{"id": "ws_chat", "type": "group"},
|
|
"company_id": "ws_comp",
|
|
"message": map[string]any{"id": "ws_msg_1", "type": "text", "content": map[string]string{"type": "text", "content": "from ws"}},
|
|
"sender": map[string]any{"id": "ws_user", "type": "user"},
|
|
}
|
|
event := encryptEventForTest(appID, appSecret, "kso.app_chat.message", "create", payload)
|
|
frameData, _ := json.Marshal(event)
|
|
conn.WriteMessage(websocket.TextMessage, frameData)
|
|
|
|
// Wait for ACK
|
|
conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
_, _, _ = conn.ReadMessage()
|
|
}))
|
|
defer wsSrv.Close()
|
|
|
|
// Override wsEndpoint temporarily
|
|
origEndpoint := wsEndpoint
|
|
wsEndpoint = "ws" + strings.TrimPrefix(wsSrv.URL, "http")
|
|
defer func() { wsEndpoint = origEndpoint }()
|
|
|
|
ch := make(chan *core.Message, 1)
|
|
p := &Platform{
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
handler: func(_ core.Platform, msg *core.Message) {
|
|
ch <- msg
|
|
},
|
|
dedup: core.MessageDedup{},
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
_ = p.runConnection(ctx)
|
|
|
|
select {
|
|
case msg := <-ch:
|
|
if msg.Content != "from ws" {
|
|
t.Fatalf("expected 'from ws', got %q", msg.Content)
|
|
}
|
|
if msg.SessionKey != "wps-xiezuo:ws_comp:ws_chat" {
|
|
t.Fatalf("unexpected session key: %s", msg.SessionKey)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
if up.Load() == 0 {
|
|
t.Skip("WebSocket server was not reached (env may not support ws dial)")
|
|
}
|
|
t.Fatal("timed out waiting for message from mock ws server")
|
|
}
|
|
}
|