面板点''连接''此前触发阻塞式扫码(最长8min)不展示二维码→挂起。修: - weixin adapter Connect: 空token返回 ErrNeedsLogin(非阻塞), 扫码走独立 login-qr/login-status - adminChannelOpen: errors.Is(ErrNeedsLogin)→409'该渠道尚未登录:请先扫码登录再连接'(不再500/挂起) - login_test.go 改用 StartLogin/LoginStatus 驱动(与旧Connect阻塞行为解耦) build/vet/test 全绿, 已部署.
123 lines
4.1 KiB
Go
123 lines
4.1 KiB
Go
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
|