package weixin_ilink import ( "bytes" "context" "io" "net/http" "sync" "testing" "time" ) // scriptedTransport answers the weixin_ilink endpoints without network I/O. It // records the host each get_qrcode_status poll hits so tests can assert the IDC // redirect routing, and returns get_bot_qrcode with a fixed QR plus get_qrcode_status // bodies from statusFn (n is the 1-based poll count). type scriptedTransport struct { qr []byte statusFn func(n int) []byte mu sync.Mutex statusHosts []string } func (t *scriptedTransport) RoundTrip(req *http.Request) (*http.Response, error) { var body []byte switch req.URL.Path { case "/ilink/bot/get_bot_qrcode": body = t.qr case "/ilink/bot/get_qrcode_status": t.mu.Lock() n := len(t.statusHosts) + 1 t.statusHosts = append(t.statusHosts, req.URL.Host) t.mu.Unlock() body = t.statusFn(n) default: return &http.Response{ StatusCode: http.StatusNotFound, Body: io.NopCloser(bytes.NewReader(nil)), Header: http.Header{}, Request: req, }, nil } return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(body)), Header: http.Header{"Content-Type": []string{"application/json"}}, Request: req, }, nil } func (t *scriptedTransport) hosts() []string { t.mu.Lock() defer t.mu.Unlock() return append([]string(nil), t.statusHosts...) } // newScriptedAdapter builds an Adapter wired to a scripted transport (no real // network), with a base URL that does not resolve — the transport intercepts it. func newScriptedAdapter(cfg Config, tr http.RoundTripper) (*Adapter, error) { return New(cfg, &http.Client{Transport: tr}, nil) } const scriptedQR = `{"qrcode":"qr-r","qrcode_img_content":"https://img/r"}` const scriptedBaseURL = "https://ilinkai.weixin.qq.com" // TestStartLogin_ScanButRedirectRoutesPoll verifies that a scaned_but_redirect // response with a redirect_host switches subsequent get_qrcode_status polls to // the https:// IDC host. func TestStartLogin_ScanButRedirectRoutesPoll(t *testing.T) { t.Parallel() tr := &scriptedTransport{ qr: []byte(scriptedQR), statusFn: func(n int) []byte { if n == 1 { return []byte(`{"status":"scaned_but_redirect","redirect_host":"idc.weixin.qq.com"}`) } return []byte(`{"status":"confirmed","bot_token":"tok-x","ilink_bot_id":"bot-x","ilink_user_id":"o-1","baseurl":"https://ilinkai.weixin.qq.com"}`) }, } ad, err := newScriptedAdapter(Config{ BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second, }, tr) if err != nil { t.Fatalf("New: %v", err) } if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } waitFor(t, func() bool { s, _ := ad.LoginStatus(context.Background()) return s == LoginStatusConfirmed }) if got := ad.Config()["bot_token"]; got != "tok-x" { t.Fatalf("bot_token = %v, want tok-x", got) } hosts := tr.hosts() if len(hosts) < 2 { t.Fatalf("status polls = %d, want >= 2 (first on base, second on redirect host)", len(hosts)) } if hosts[0] != "ilinkai.weixin.qq.com" { t.Fatalf("first status poll host = %q, want ilinkai.weixin.qq.com", hosts[0]) } if hosts[1] != "idc.weixin.qq.com" { t.Fatalf("second status poll host = %q, want idc.weixin.qq.com (IDC redirect)", hosts[1]) } } // TestStartLogin_VerifyCodeBlocked verifies verify_code_blocked transitions the // login to failed. func TestStartLogin_VerifyCodeBlocked(t *testing.T) { t.Parallel() tr := &scriptedTransport{ qr: []byte(scriptedQR), statusFn: func(int) []byte { return []byte(`{"status":"verify_code_blocked"}`) }, } ad, err := newScriptedAdapter(Config{ BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 2 * time.Second, }, tr) if err != nil { t.Fatalf("New: %v", err) } if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } waitFor(t, func() bool { s, _ := ad.LoginStatus(context.Background()) return s == LoginStatusFailed }) if ad.IsConnected() { t.Fatal("IsConnected() = true after verify_code_blocked") } } // TestStartLogin_BindedRedirect verifies binded_redirect is treated as already // bound (LoginStatus confirmed) rather than a failure. func TestStartLogin_BindedRedirect(t *testing.T) { t.Parallel() tr := &scriptedTransport{ qr: []byte(scriptedQR), statusFn: func(int) []byte { return []byte(`{"status":"binded_redirect"}`) }, } ad, err := newScriptedAdapter(Config{ BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 2 * time.Second, }, tr) if err != nil { t.Fatalf("New: %v", err) } if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } waitFor(t, func() bool { s, _ := ad.LoginStatus(context.Background()) return s == LoginStatusConfirmed }) if _, detail := ad.LoginStatus(context.Background()); detail != loginBindedRedirectDetail { t.Fatalf("binded_redirect detail = %q, want %q", detail, loginBindedRedirectDetail) } } // TestStartLogin_NeedVerifyCode verifies need_verifycode surfaces a dedicated // LoginStatus sub-state with a hint detail, then times out to failed. func TestStartLogin_NeedVerifyCode(t *testing.T) { t.Parallel() tr := &scriptedTransport{ qr: []byte(scriptedQR), statusFn: func(int) []byte { return []byte(`{"status":"need_verifycode"}`) }, } ad, err := newScriptedAdapter(Config{ BaseURL: scriptedBaseURL, LoginInterval: time.Millisecond, LoginTimeout: 40 * time.Millisecond, }, tr) if err != nil { t.Fatalf("New: %v", err) } if _, err := ad.StartLogin(context.Background()); err != nil { t.Fatalf("StartLogin: %v", err) } waitFor(t, func() bool { s, _ := ad.LoginStatus(context.Background()) return s == LoginStatusNeedVerifyCode }) if _, detail := ad.LoginStatus(context.Background()); detail != loginNeedVerifyCodeHint { t.Fatalf("need_verifycode detail = %q, want %q", detail, loginNeedVerifyCodeHint) } // Without a verify-code回传 path the flow eventually times out to failed. waitFor(t, func() bool { s, _ := ad.LoginStatus(context.Background()) return s == LoginStatusFailed }) }