package core import ( "fmt" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "llmsproxy/internal/lua" ) // loomyHarness:Loomy 微信扫码长轮询的测试台。 // // 参考语义(loomy-wechat.ts 点名,早期版本**读反了**导致"扫码后卡在已扫码"): // // 408 = 等待扫码;404 = 已扫码待确认(继续轮询); // 405 = **已确认**(wx_code 就在这一帧);403 = 取消;402 = 失效 type loomyHarness struct { t *testing.T ps *lua.Plugins ts *httptest.Server polls int32 confirmAt int32 gotAuth string } func newLoomyHarness(t *testing.T) *loomyHarness { t.Helper() h := &loomyHarness{t: t, confirmAt: 3} h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/connect/qrconnect"): w.Header().Set("Content-Type", "text/html") // 夹具必须用**真实微信页面的形态**:uuid 内嵌在 // 。 // 用别的形态(如 var uuid = "...")测出来的正则不是生产要用的那条。 fmt.Fprint(w, ``) case strings.HasPrefix(r.URL.Path, "/connect/l/qrconnect"): n := atomic.AddInt32(&h.polls, 1) w.Header().Set("Content-Type", "text/plain") if n == 1 { fmt.Fprint(w, "window.wx_errcode=408;") // 等待扫码 return } if n < atomic.LoadInt32(&h.confirmAt) { fmt.Fprint(w, "window.wx_errcode=404;") // 已扫码待确认 return } fmt.Fprint(w, `window.wx_errcode=405;window.wx_code='WX-CODE-1';`) // 已确认 case r.URL.Path == "/login/thirdAccount/bind/auth": h.gotAuth = r.Header.Get("Authorization") w.Header().Set("Content-Type", "application/json") fmt.Fprint(w, `{"code":0,"data":{"rcode":"RCODE-1","bind":1,"nickname":"loomy-dev","userid":"u5"}}`) default: w.WriteHeader(404) } })) t.Cleanup(h.ts.Close) c := kernelTestCore(t) h.ps = c.Plugins() src, err := readBundledChannels(t) if err != nil { t.Fatal(err) } if err := h.ps.LoadSource("channels", src); err != nil { t.Fatal(err) } _ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false) if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{ "platform": "loomy", "wechat": map[string]interface{}{ "auth_url": h.ts.URL + "/connect/qrconnect?state={state}", "qr_url": h.ts.URL + "/connect/qrcode/{uuid}", "poll_url": h.ts.URL + "/connect/l/qrconnect", "account_base": h.ts.URL, }, }); err != nil { t.Fatalf("set_endpoints: %v", err) } return h } func (h *loomyHarness) begin() (map[string]interface{}, string) { out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{ Method: "POST", Body: `{"platform":"loomy"}`, Role: "admin", }) if err != nil { h.t.Fatal(err) } j := out.JSON.(map[string]interface{}) return j, j["session"].(map[string]interface{})["id"].(string) } func (h *loomyHarness) pollOnce(sid string) map[string]interface{} { out, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{ Method: "GET", Query: map[string]string{"id": sid}, Role: "admin", }) if err != nil { h.t.Fatal(err) } return out.JSON.(map[string]interface{}) } // TestLoomyWechatPollBehavior 逐行锁定微信长轮询的状态判据。 func TestLoomyWechatPollBehavior(t *testing.T) { h := newLoomyHarness(t) t.Run("行1 授权页抽出uuid并给出二维码", func(t *testing.T) { atomic.StoreInt32(&h.polls, 0) j, _ := h.begin() qr, _ := j["qr_url"].(string) if !strings.Contains(qr, "UUID-TEST-1") { t.Fatalf("二维码 URL 应含抽出的 uuid, 实得 %q", qr) } }) t.Run("行2 408等待_404已扫码但不算完成", func(t *testing.T) { atomic.StoreInt32(&h.polls, 0) atomic.StoreInt32(&h.confirmAt, 99) _, sid := h.begin() // 第一拍 408 if st := h.pollOnce(sid); st["state"] != "pending" { t.Fatalf("408 应 pending, 实得 %v", st["state"]) } // 第二拍 404 —— **关键**:已扫码但**不是**完成(早期读反会卡这里) st := h.pollOnce(sid) if st["state"] != "pending" { t.Fatalf("404(已扫码待确认) 必须是 pending 而非 completed, 实得 %v", st["state"]) } }) t.Run("行3 405才算确认_并换到凭据", func(t *testing.T) { atomic.StoreInt32(&h.polls, 0) atomic.StoreInt32(&h.confirmAt, 3) _, sid := h.begin() h.pollOnce(sid) // 408 h.pollOnce(sid) // 404 st := h.pollOnce(sid) // 405 if st["state"] != "completed" { t.Fatalf("405 应 completed, 实得 %v (msg=%v)", st["state"], st["message"]) } // 账号端点必须用讯飞签名头(account ak:sig),不是 Bearer if !strings.HasPrefix(h.gotAuth, "account ") { t.Fatalf("账号端点应用 account ak:sig 签名, 实得 %q", h.gotAuth) } if !strings.Contains(h.gotAuth, ":") { t.Fatalf("签名头应含冒号分隔的 ak:sig, 实得 %q", h.gotAuth) } v, ok, _ := h.ps.GetSecret("channels", "channels:loomy") if !ok || !strings.Contains(v, "RCODE-1") { t.Fatalf("凭据应含 rcode, 实得 %q", v) } }) t.Run("行4 403取消_402失效_都是终态失败", func(t *testing.T) { for _, tc := range []struct{ errcode, want string }{ {"403", "取消"}, {"402", "失效"}, } { hh := newLoomyHarness(t) hh.ts.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/connect/qrconnect") { fmt.Fprint(w, ``) return } fmt.Fprintf(w, "window.wx_errcode=%s;", tc.errcode) }) _, sid := hh.begin() st := hh.pollOnce(sid) if st["state"] != "failed" { t.Fatalf("%s 应 failed, 实得 %v", tc.errcode, st["state"]) } } }) }