package weixin_ilink import ( "context" "encoding/json" "errors" "net/http" "testing" "time" ) // TestStartLogin_ScanStatusTransition 经带外 API 驱动二维码绑定流程:StartLogin // 生成二维码,后台轮询走过 wait→scaned→confirmed 并保存凭据,LoginStatus 呈现 // confirmed。 func TestStartLogin_ScanStatusTransition(t *testing.T) { t.Parallel() rec := &reqRecorder{} statusCalls := 0 srv := newJSONServer(t, rec, func(req recordedReq) handlerResult { switch req.path { case "/ilink/bot/get_bot_qrcode": return handlerResult{body: jsonBody(map[string]any{ "qrcode": "qr-1", "qrcode_img_content": "https://img/qr", })} case "/ilink/bot/get_qrcode_status": statusCalls++ switch statusCalls { case 1: return handlerResult{body: jsonBody(map[string]any{"status": "wait"})} case 2: return handlerResult{body: jsonBody(map[string]any{"status": "scaned"})} default: return handlerResult{body: jsonBody(map[string]any{ "status": "confirmed", "bot_token": "tok-x", "ilink_bot_id": "bot-x", "ilink_user_id": "o-1", "baseurl": "https://ilinkai.weixin.qq.com", })} } } return handlerResult{status: 404} }) cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second} ad := newAdapter(t, cfg, srv) qr, err := ad.StartLogin(context.Background()) if err != nil { t.Fatalf("StartLogin: %v", err) } if qr.Qrcode == "" { t.Fatal("StartLogin returned empty qrcode") } // 轮询 LoginStatus,直到后台流程确认。 deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { status, _ := ad.LoginStatus(context.Background()) if status == LoginStatusConfirmed { break } time.Sleep(5 * time.Millisecond) } if !ad.IsConnected() { t.Fatal("IsConnected() = false after confirmed scan login") } if got := ad.Config()["bot_token"]; got != "tok-x" { t.Fatalf("bot_token = %v, want tok-x", got) } if got := ad.Config()["bot_id"]; got != "bot-x" { t.Fatalf("bot_id = %v, want bot-x", got) } // 确认 qrcode id 在状态轮询的查询串与认证头中被回传。 for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_qrcode_status" }) { if q.query != "qrcode=qr-1" { t.Fatalf("status poll query = %q, want qrcode=qr-1", q.query) } if q.authType != "ilink_bot_token" { t.Fatalf("status poll AuthorizationType = %q, want ilink_bot_token", q.authType) } } // 确认取码遵循官方协议:POST、iLink App 标识头,以及携带 local_token_list 的 // 请求体(全新绑定时为空)。 for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" }) { if q.method != http.MethodPost { t.Fatalf("get_bot_qrcode method = %q, want POST", q.method) } if q.appID != "bot" { t.Fatalf("get_bot_qrcode iLink-App-Id = %q, want bot", q.appID) } if q.appClientVer != "132104" { t.Fatalf("get_bot_qrcode iLink-App-ClientVersion = %q, want 132104", q.appClientVer) } if q.query != "bot_type=3" { t.Fatalf("get_bot_qrcode query = %q, want bot_type=3", q.query) } ltl, ok := q.bodyJSON()["local_token_list"] if !ok { t.Fatalf("get_bot_qrcode body missing local_token_list: %s", q.body) } if ltl == nil { t.Fatalf("get_bot_qrcode local_token_list = nil, want empty array []") } } } // TestStartLogin_Timeout 验证二维码始终未确认时绑定以 ErrLoginTimeout 终止,且空 token // 的 Connect **不**阻塞(ErrNeedsLogin)。 func TestStartLogin_Timeout(t *testing.T) { t.Parallel() srv := newJSONServer(t, nil, func(req recordedReq) handlerResult { if req.path == "/ilink/bot/get_bot_qrcode" { return handlerResult{body: jsonBody(map[string]any{"qrcode": "qr-t"})} } return handlerResult{body: jsonBody(map[string]any{"status": "wait"})} }) cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 25 * time.Millisecond} ad := newAdapter(t, cfg, srv) // 无 token 的 Connect 绝不能阻塞在二维码上——它上报 ErrNeedsLogin,让调用方把 // 主人引向带外的扫码登录面板。 if err := ad.Connect(context.Background()); !errors.Is(err, ErrNeedsLogin) { t.Fatalf("Connect(empty token) = %v, want ErrNeedsLogin", err) } // 带外绑定若始终未确认,同样会超时。 if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } deadline := time.Now().Add(2 * time.Second) var lastStatus string for time.Now().Before(deadline) { lastStatus, _ = ad.LoginStatus(context.Background()) if lastStatus == LoginStatusFailed { break } time.Sleep(5 * time.Millisecond) } if lastStatus != LoginStatusFailed { t.Fatalf("LoginStatus = %q, want failed after timeout", lastStatus) } if ad.IsConnected() { t.Fatal("IsConnected() = true after failed login") } } var _ = json.RawMessage{} // 保留 encoding/json 的导入,供后续用例使用