feat(channels/weixin_ilink): 微信 iLink 渠道插件(扫码登录+长轮询+游标+context_token+AES-128-ECB 媒体+24h 主动推送窗口)
This commit is contained in:
1 parent
7831b0ea1b
commit
88dc12466d
13 files changed
+2627
No files matched your search
@@ -0,0 +1,197 @@
|
||||
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 is the decision a test's fake handler returns for a request.
|
||||
type handlerResult struct {
|
||||
status int
|
||||
body []byte
|
||||
ctype string
|
||||
}
|
||||
|
||||
// reqRecorder captures the requests a fake server receives so tests can assert
|
||||
// that the adapter sent the expected cursor, bearer token, payload, etc.
|
||||
type reqRecorder struct {
|
||||
mu sync.Mutex
|
||||
reqs []recordedReq
|
||||
}
|
||||
|
||||
// recordedReq is a normalized snapshot of an inbound HTTP request.
|
||||
type recordedReq struct {
|
||||
method string
|
||||
path string
|
||||
query string
|
||||
body []byte
|
||||
auth string
|
||||
authType string
|
||||
uin 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 returns requests matching 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 decodes a request body into a plain map, returning nil on failure.
|
||||
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 marshals v into a JSON byte slice.
|
||||
func jsonBody(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// inlineJSON builds a json.RawMessage that inlines when placed in an []any, so
|
||||
// the fake server can embed pre-serialized message objects into a msgs array.
|
||||
func inlineJSON(v any) json.RawMessage { return json.RawMessage(jsonBody(v)) }
|
||||
|
||||
// encodeToken builds a base64 bot_token whose trailing bytes are a known key, so
|
||||
// deriveInboundKey yields a deterministic AES key (design/08 §3.3).
|
||||
func encodeToken(key []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(append([]byte("prefix!"), key...))
|
||||
}
|
||||
|
||||
// newJSONServer starts an httptest server whose handler records each request
|
||||
// and answers via fn. The server is closed on test cleanup.
|
||||
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,
|
||||
auth: r.Header.Get("Authorization"),
|
||||
authType: r.Header.Get("AuthorizationType"),
|
||||
uin: r.Header.Get("X-WECHAT-UIN"),
|
||||
}
|
||||
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)
|
||||
w.WriteHeader(res.status)
|
||||
_, _ = w.Write(res.body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// newAdapter builds an Adapter wired to a test server. The caller controls the
|
||||
// Config (BaseURL overridden from the server when left blank).
|
||||
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 builds a connected Adapter carrying a bot token.
|
||||
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 guards that the production adapter satisfies the contract at
|
||||
// compile time.
|
||||
var _ adapter.PlatformAdapter = (*Adapter)(nil)
|
||||
|
||||
// waitFor polls fn until it returns true or the timeout elapses, failing the
|
||||
// test on timeout.
|
||||
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 returns the recorded getupdates requests.
|
||||
func getupdatesReqs(rec *reqRecorder) []recordedReq {
|
||||
return rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getupdates" })
|
||||
}
|
||||
|
||||
// onceUpdates serves first (a raw getupdates body) exactly once, then empty
|
||||
// polls. Read-loop tests use it so a repeated message is not double-emitted.
|
||||
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"})}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user