207 lines
5.6 KiB
Go
207 lines
5.6 KiB
Go
package weixin_ilink
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
)
|
|
|
|
// handlerResult 是测试里的假处理器对一次请求给出的裁决。headers 用于模拟 CDN 上传
|
|
// 成功时回填凭据的响应头(`x-encrypted-param`)。
|
|
type handlerResult struct {
|
|
status int
|
|
body []byte
|
|
ctype string
|
|
headers map[string]string
|
|
}
|
|
|
|
// reqRecorder 捕获假服务端收到的请求,使测试能断言适配器发送了预期的游标、
|
|
// bearer token、载荷等。
|
|
type reqRecorder struct {
|
|
mu sync.Mutex
|
|
reqs []recordedReq
|
|
}
|
|
|
|
// recordedReq 是一条入站 HTTP 请求的归一化快照。
|
|
type recordedReq struct {
|
|
method string
|
|
path string
|
|
query string
|
|
body []byte
|
|
ctype string
|
|
auth string
|
|
authType string
|
|
uin string
|
|
appID string
|
|
appClientVer string
|
|
}
|
|
|
|
func (r *reqRecorder) add(req recordedReq) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.reqs = append(r.reqs, req)
|
|
}
|
|
|
|
func (r *reqRecorder) all() []recordedReq {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]recordedReq(nil), r.reqs...)
|
|
}
|
|
|
|
func (r *reqRecorder) reset() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.reqs = nil
|
|
}
|
|
|
|
// find 返回匹配 pred 的请求。
|
|
func (r *reqRecorder) find(pred func(recordedReq) bool) []recordedReq {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
var out []recordedReq
|
|
for _, q := range r.reqs {
|
|
if pred(q) {
|
|
out = append(out, q)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// bodyJSON 把请求体解码为普通 map,失败时返回 nil。
|
|
func (q recordedReq) bodyJSON() map[string]any {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(q.body, &m); err != nil {
|
|
return nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
// jsonBody 把 v 序列化成 JSON 字节切片。
|
|
func jsonBody(v any) []byte {
|
|
b, _ := json.Marshal(v)
|
|
return b
|
|
}
|
|
|
|
// inlineJSON 构造一个放进 []any 时会内联的 json.RawMessage,便于假服务端把预序列化
|
|
// 的消息对象嵌进 msgs 数组。
|
|
func inlineJSON(v any) json.RawMessage { return json.RawMessage(jsonBody(v)) }
|
|
|
|
// encodeToken 构造一个 base64 bot_token,其末尾字节是已知密钥,使 deriveInboundKey
|
|
// 得到确定性的 AES 密钥(见 design/08 §4 的 weixin-ilink 条目;旧 §3.3 口径已作废)。
|
|
func encodeToken(key []byte) string {
|
|
return base64.StdEncoding.EncodeToString(append([]byte("prefix!"), key...))
|
|
}
|
|
|
|
// newJSONServer 启动一个 httptest 服务端,其处理器记录每个请求并通过 fn 作答。
|
|
// 测试清理时该服务端会被关闭。
|
|
func newJSONServer(t *testing.T, rec *reqRecorder, fn func(r recordedReq) handlerResult) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
req := recordedReq{
|
|
method: r.Method,
|
|
path: r.URL.Path,
|
|
query: r.URL.RawQuery,
|
|
body: body,
|
|
ctype: r.Header.Get("Content-Type"),
|
|
auth: r.Header.Get("Authorization"),
|
|
authType: r.Header.Get("AuthorizationType"),
|
|
uin: r.Header.Get("X-WECHAT-UIN"),
|
|
appID: r.Header.Get("iLink-App-Id"),
|
|
appClientVer: r.Header.Get("iLink-App-ClientVersion"),
|
|
}
|
|
if rec != nil {
|
|
rec.add(req)
|
|
}
|
|
res := fn(req)
|
|
if res.ctype == "" {
|
|
res.ctype = "application/json"
|
|
}
|
|
if res.status == 0 {
|
|
res.status = http.StatusOK
|
|
}
|
|
w.Header().Set("Content-Type", res.ctype)
|
|
for k, v := range res.headers {
|
|
w.Header().Set(k, v)
|
|
}
|
|
w.WriteHeader(res.status)
|
|
_, _ = w.Write(res.body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// newAdapter 构造一个接到测试服务端的 Adapter。Config 由调用方掌控(BaseURL 留空时
|
|
// 用服务端地址覆盖)。
|
|
func newAdapter(t *testing.T, cfg Config, srv *httptest.Server) *Adapter {
|
|
t.Helper()
|
|
if srv != nil && cfg.BaseURL == "" {
|
|
cfg.BaseURL = srv.URL
|
|
}
|
|
var client *http.Client
|
|
if srv != nil {
|
|
client = srv.Client()
|
|
}
|
|
ad, err := New(cfg, client, nil)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
return ad
|
|
}
|
|
|
|
// newConnectedAdapter 构造一个带 bot token 的已连接 Adapter。
|
|
func newConnectedAdapter(t *testing.T, cfg Config, srv *httptest.Server) *Adapter {
|
|
t.Helper()
|
|
ad := newAdapter(t, cfg, srv)
|
|
if err := ad.Connect(context.Background()); err != nil {
|
|
t.Fatalf("Connect: %v", err)
|
|
}
|
|
return ad
|
|
}
|
|
|
|
// conformance 在编译期守护生产适配器满足该契约。
|
|
var _ adapter.PlatformAdapter = (*Adapter)(nil)
|
|
|
|
// waitFor 轮询 fn 直到它返回 true 或超时,超时则让测试失败。
|
|
func waitFor(t *testing.T, fn func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if fn() {
|
|
return
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
t.Fatal("condition not met within timeout")
|
|
}
|
|
|
|
// getupdatesReqs 返回已记录的 getupdates 请求。
|
|
func getupdatesReqs(rec *reqRecorder) []recordedReq {
|
|
return rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getupdates" })
|
|
}
|
|
|
|
// onceUpdates 恰好一次返回 first(一份原始 getupdates 响应体),之后返回空轮询。
|
|
// 读循环测试用它,避免重复的消息被重复投递。
|
|
func onceUpdates(t *testing.T, rec *reqRecorder, first []byte) *httptest.Server {
|
|
t.Helper()
|
|
calls := 0
|
|
return newJSONServer(t, rec, func(req recordedReq) handlerResult {
|
|
if req.path != "/ilink/bot/getupdates" {
|
|
return handlerResult{status: 404}
|
|
}
|
|
calls++
|
|
if calls == 1 {
|
|
return handlerResult{body: first}
|
|
}
|
|
return handlerResult{body: jsonBody(map[string]any{"msgs": []any{}, "get_updates_buf": "c2"})}
|
|
})
|
|
}
|