Files
Cyrene_v2/plugins/channels/weixin_ilink/login_admin_test.go
T
Aska e037fab7d0
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(weixin): 微信iLink扫码登录——面板触发+二维码显示+状态轮询+确认热连接
适配器: login_admin.go(StartLogin 幂等取二维码/LoginStatus idle-waiting-confirmed-failed);
       login.go 重写 Connect 委托 StartLogin+等待,后台 runLogin 轮询,completeLogin/failLogin
       (锁序 loginMu->a.mu 不嵌套反向无死锁); adapter.go 加登录会话字段
网关: admin_channels_weixin.go(2 端点 login-qr/login-status owner+审计, confirmed 合并写回
       channels.json 保留 base_url/cdn/黑白名单 + Reload 热重载连接; qrImage best-effort Base64,
       token 掩码) + server.go 路由
前端: WeixinLogin.tsx(token空→扫码按钮→二维码→1.5s轮询→confirmed提示已登录+显示bot_id),
       AdapterDetailPage 渲染, api/types 补类型
必需偏离(最小化): 允许空token的weixin账号被构建/持久化(channels.go+runtimeconfig移出
       tokenRequiringPlatforms),否则空token账号无法注册调用StartLogin; 空token保持断开(configured
       仍要求token非空),确认后才持久化连接——守红线。
测试: login_admin/admin端点用例; 后端 build/vet/test 全绿 + 前端 build 101模块绿.
2026-09-07 20:50:28 +08:00

107 lines
3.7 KiB
Go

package weixin_ilink
import (
"context"
"errors"
"testing"
"time"
)
// TestStartLogin_WaitThenConfirm drives the panel flow: StartLogin returns the QR,
// the background poll confirms the bind, and LoginStatus transitions.
func TestStartLogin_WaitThenConfirm(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
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": "data:image/png;base64,QUJD",
})}
case "/ilink/bot/get_qrcode_status":
return handlerResult{body: jsonBody(map[string]any{
"status": "confirmed", "bot_token": "tok-x",
"ilink_bot_id": "bot-x", "ilink_user_id": "o-1",
})}
}
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 != "qr-1" || qr.Image != "data:image/png;base64,QUJD" {
t.Fatalf("StartLogin returned %+v, want qr-1 + image", qr)
}
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
t.Fatalf("LoginStatus right after StartLogin = %q, want waiting", status)
}
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)
}
}
// TestStartLogin_IdempotentWhileWaiting verifies a second StartLogin while a login
// is in progress returns the same QR and never issues a second get_bot_qrcode.
func TestStartLogin_IdempotentWhileWaiting(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
if req.path == "/ilink/bot/get_bot_qrcode" {
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-2", "qrcode_img_content": "https://img/qr",
})}
}
return handlerResult{body: jsonBody(map[string]any{"status": "wait"})}
})
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Hour, LoginTimeout: 5 * time.Second}
ad := newAdapter(t, cfg, srv)
first, err := ad.StartLogin(context.Background())
if err != nil {
t.Fatalf("first StartLogin: %v", err)
}
second, err := ad.StartLogin(context.Background())
if err != nil {
t.Fatalf("second StartLogin: %v", err)
}
if first.Qrcode != second.Qrcode {
t.Fatalf("second StartLogin qrcode = %q, want %q (idempotent)", second.Qrcode, first.Qrcode)
}
if got := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })); got != 1 {
t.Fatalf("get_bot_qrcode called %d times, want 1", got)
}
}
// TestStartLogin_AlreadyLoggedIn verifies StartLogin refuses when a token exists and
// LoginStatus reports confirmed.
func TestStartLogin_AlreadyLoggedIn(t *testing.T) {
t.Parallel()
ad := newAdapter(t, Config{BaseURL: "https://example.invalid", BotToken: "tok-pre"}, nil)
_, err := ad.StartLogin(context.Background())
if !errors.Is(err, ErrAlreadyLoggedIn) {
t.Fatalf("StartLogin err = %v, want ErrAlreadyLoggedIn", err)
}
if status, detail := ad.LoginStatus(context.Background()); status != LoginStatusConfirmed || detail != "" {
t.Fatalf("LoginStatus = (%q, %q), want confirmed/empty", status, detail)
}
}
// TestLoginStatus_Idle verifies a fresh adapter reports idle.
func TestLoginStatus_Idle(t *testing.T) {
t.Parallel()
ad := newAdapter(t, Config{BaseURL: "https://example.invalid"}, nil)
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusIdle {
t.Fatalf("LoginStatus = %q, want idle", status)
}
}