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