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"})} }) }