Files
Cyrene_v2/plugins/channels/weixin_ilink/send_test.go
T
AskaEth 95f97f5df3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · plugins/channels/weixin_ilink(20 文件,纯替换)
- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(8 组,代码零改动)
- 保留协议/线上标识符原文:errcode、bot_token、context_token、getupdates、binded_redirect、PKCS#7、ECB 等
- 术语对齐既有译法:owner→主人、peer→对端、long poll→长轮询、backoff→退避、sentinel error→哨兵错误
- 已知既有欠债(未顺手修):login_reuse_test.go 在 HEAD 上就未 gofmt 干净;login_test.go 的 TestStartLogin_ScanStatusTransition 91 行 >80(HEAD 同为 91 行)
2026-10-04 12:29:05 +08:00

230 lines
7.3 KiB
Go

package weixin_ilink
import (
"bytes"
"context"
"crypto/aes"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// sendOKServer 对 sendmessage 回以 ret:0,并记录每个请求。
func sendOKServer(t *testing.T, rec *reqRecorder) *httptest.Server {
t.Helper()
return newJSONServer(t, rec, func(req recordedReq) handlerResult {
if req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage" {
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
}
return handlerResult{status: 404}
})
}
func TestSendMessage_RequiresConnection(t *testing.T) {
t.Parallel()
ad, err := New(Config{BaseURL: "https://ilinkai.weixin.qq.com"}, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}},
})
if !errors.Is(err, adapter.ErrNotConnected) {
t.Fatalf("err = %v, want ErrNotConnected", err)
}
}
func TestSendMessage_RequiresContextToken(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := sendOKServer(t, rec)
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}},
})
if !errors.Is(err, ErrNoContextToken) {
t.Fatalf("err = %v, want ErrNoContextToken", err)
}
}
func TestSendMessage_SendsTextReply(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := sendOKServer(t, rec)
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
resp := adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "pong"}},
ReplyTo: "anything",
}
if err := ad.SendMessage(context.Background(), resp); err != nil {
t.Fatalf("SendMessage: %v", err)
}
body := getSendBody(t, rec)
msg := body["msg"].(map[string]any)
if msg["to_user_id"] != "u-1" {
t.Fatalf("to_user_id = %v, want u-1", msg["to_user_id"])
}
if msg["context_token"] != "tok-1" {
t.Fatalf("context_token = %v, want tok-1", msg["context_token"])
}
item := msg["item_list"].([]any)[0].(map[string]any)
if typ, ok := item["type"].(float64); !ok || typ != 1 {
t.Fatalf("item type = %v, want 1", item["type"])
}
if txt := item["text_item"].(map[string]any)["text"]; txt != "pong" {
t.Fatalf("text = %v, want pong", txt)
}
if msg["client_id"] == "" || msg["client_id"] == nil {
t.Fatal("client_id missing")
}
}
func TestSendProactive_WithinWindow(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := sendOKServer(t, rec)
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
ad.clock = func() time.Time { return now }
ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now})
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
SenderID: "u-1", Content: "daily tip",
})
if err != nil {
t.Fatalf("SendProactive within window: %v", err)
}
body := getSendBody(t, rec)
msg := body["msg"].(map[string]any)
if msg["to_user_id"] != "u-1" || msg["context_token"] != "tok-p" {
t.Fatalf("proactive send = to_user_id %v context %v", msg["to_user_id"], msg["context_token"])
}
}
func TestSendProactive_ExpiredWindow(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := sendOKServer(t, rec)
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
ad.clock = func() time.Time { return now }
ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now})
// 推进到 24 小时窗口之外。
now = now.Add(25 * time.Hour)
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
SenderID: "u-1", Content: "too late",
})
if !errors.Is(err, ErrProactiveWindow) {
t.Fatalf("err = %v, want ErrProactiveWindow", err)
}
if len(getupdatesReqs(rec)) != 0 {
t.Fatal("expired window must not send")
}
}
func TestSendProactive_NoPriorInbound(t *testing.T) {
t.Parallel()
srv := sendOKServer(t, &reqRecorder{})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
SenderID: "u-1", Content: "hello",
})
if !errors.Is(err, ErrProactiveWindow) {
t.Fatalf("err = %v, want ErrProactiveWindow (no prior inbound)", err)
}
}
func TestSendMessage_OutboundMedia(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
var srv *httptest.Server
srv = newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch {
case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl":
return handlerResult{body: jsonBody(map[string]any{"upload_url": srv.URL + "/upload/1"})}
case req.method == http.MethodPut && req.path == "/upload/1":
return handlerResult{status: 200, body: []byte("ok")}
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
default:
return handlerResult{status: 404}
}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
imageData := []byte("fake-jpeg-bytes")
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaImage, Data: imageData, Name: "a.jpg"}},
}},
})
if err != nil {
t.Fatalf("SendMessage media: %v", err)
}
body := getSendBody(t, rec)
msg := body["msg"].(map[string]any)
item := msg["item_list"].([]any)[0].(map[string]any)
if typ, ok := item["type"].(float64); !ok || typ != 2 {
t.Fatalf("media item type = %v, want 2", item["type"])
}
if item["cdn_ref"] != srv.URL+"/upload/1" {
t.Fatalf("cdn_ref = %v, want upload url", item["cdn_ref"])
}
aesKeyEncoded, _ := item["aes_key"].(string)
if aesKeyEncoded == "" {
t.Fatal("aes_key missing on media item")
}
key, err := base64.StdEncoding.DecodeString(aesKeyEncoded)
if err != nil {
t.Fatalf("decode aes_key: %v", err)
}
if len(key) != aes.BlockSize {
t.Fatalf("aes_key = %d bytes, want %d", len(key), aes.BlockSize)
}
// 已上传的字节(PUT 请求体)必须能解密回原始媒体。
puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut })
if len(puts) != 1 {
t.Fatalf("PUT requests = %d, want 1", len(puts))
}
decrypted, err := aesECBDecrypt(key, puts[0].body)
if err != nil {
t.Fatalf("decrypt uploaded media: %v", err)
}
if !bytes.Equal(decrypted, imageData) {
t.Fatalf("uploaded media mismatch: got %q want %q", decrypted, imageData)
}
}
// getSendBody 返回第一个 sendmessage 请求的解码后请求体。
func getSendBody(t *testing.T, rec *reqRecorder) map[string]any {
t.Helper()
reqs := rec.find(func(q recordedReq) bool {
return q.method == http.MethodPost && q.path == "/ilink/bot/sendmessage"
})
if len(reqs) == 0 {
t.Fatal("no sendmessage request recorded")
}
body := reqs[0].bodyJSON()
if body == nil {
t.Fatal("sendmessage body is not JSON")
}
return body
}