package weixin_ilink import ( "context" "encoding/json" "errors" "testing" "time" ) // TestStartLogin_ScanStatusTransition drives the QR bind flow via the out-of-band // API: StartLogin mints the QR, the background poll walks wait→scaned→confirmed and // stores the credentials, and LoginStatus surfaces 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") } // Poll LoginStatus until the background flow confirms. 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) } // Confirm the qrcode id is echoed in the status poll query and the auth header. 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) } } } // TestStartLogin_Timeout verifies the bind aborts with ErrLoginTimeout when the QR // is never confirmed, and Connect with an empty token does NOT block (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) // Connect with no token must NOT block on the QR — it surfaces ErrNeedsLogin so // the caller routes the owner to the out-of-band 扫码登录 panel. if err := ad.Connect(context.Background()); !errors.Is(err, ErrNeedsLogin) { t.Fatalf("Connect(empty token) = %v, want ErrNeedsLogin", err) } // The out-of-band bind still times out if never confirmed. 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{} // keep encoding/json imported for future cases