// wechat 客户端单测:code 换 openid(httptest 打桩微信服务端)+ 扫码授权 URL 构造。 // // 不连真微信:BaseURL 注入 httptest.Server,覆盖成功 / errcode!=0 / 网络故障 / 响应非法 JSON, // 以及 BuildQRConnectURL 的参数完整性(appid/redirect_uri/scope= snsapi_login/state + #wechat_redirect)。 package wechat import ( "context" "net/http" "net/http/httptest" "regexp" "strings" "testing" "time" ) // newStub 起一个 httptest 微信服务替身,按 handler 返回预置响应。 func newStub(t *testing.T, handler http.HandlerFunc) *HTTPClient { t.Helper() srv := httptest.NewServer(handler) t.Cleanup(srv.Close) return &HTTPClient{HTTP: &http.Client{Timeout: 2 * time.Second}, BaseURL: srv.URL} } // TestExchangeCode_Success 微信返回 openid:ExchangeCode 应取回原值。 func TestExchangeCode_Success(t *testing.T) { var gotAppID, gotSecret, gotCode, gotRedirect string c := newStub(t, func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() gotAppID = q.Get("appid") gotSecret = q.Get("secret") gotCode = q.Get("code") gotRedirect = q.Get("redirect_uri") _, _ = w.Write([]byte(`{"access_token":"at-1","openid":"openid-x","errcode":0,"errmsg":"ok"}`)) }) oid, err := c.ExchangeCode(context.Background(), "appid-a", "secret-b", "code-1", "https://cb.test/auth/wechat/callback") if err != nil { t.Fatalf("ExchangeCode: %v", err) } if oid != "openid-x" { t.Fatalf("openid=%q(期望 openid-x)", oid) } if gotAppID != "appid-a" || gotSecret != "secret-b" || gotCode != "code-1" { t.Fatalf("请求参数不符:appid=%q secret=%q code=%q", gotAppID, gotSecret, gotCode) } if gotRedirect != "https://cb.test/auth/wechat/callback" { t.Fatalf("redirect_uri=%q(期望回调地址原样透传)", gotRedirect) } } // TestExchangeCode_WechatReject errcode!=0:返回 *errToken,IsTokenError 可判。 func TestExchangeCode_WechatReject(t *testing.T) { c := newStub(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"errcode":40029,"errmsg":"invalid code"}`)) }) _, err := c.ExchangeCode(context.Background(), "a", "s", "bad", "") if err == nil { t.Fatal("errcode!=0 应返回 error") } if !IsTokenError(err) { t.Fatalf("应为 *errToken,实际 %T: %v", err, err) } } // TestExchangeCode_EmptyOpenID errcode=0 但 openid 缺失:按失败处理(防半成功态)。 func TestExchangeCode_EmptyOpenID(t *testing.T) { c := newStub(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"access_token":"at","errcode":0,"errmsg":"ok"}`)) }) _, err := c.ExchangeCode(context.Background(), "a", "s", "code", "") if err == nil { t.Fatal("errcode=0 但 openid 空应返回 error(防半成功态)") } } // TestExchangeCode_NonJSON 响应非 JSON:返回 error(fail-closed,不误判成功)。 func TestExchangeCode_NonJSON(t *testing.T) { c := newStub(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("bad gateway")) }) _, err := c.ExchangeCode(context.Background(), "a", "s", "code", "") if err == nil { t.Fatal("非 JSON 响应应返回 error") } } // TestExchangeCode_NetworkFail 网络故障(关 server):返回 *errToken。 func TestExchangeCode_NetworkFail(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) c := &HTTPClient{HTTP: &http.Client{Timeout: 2 * time.Second}, BaseURL: srv.URL} srv.Close() // 关服务 → 连接失败 _, err := c.ExchangeCode(context.Background(), "a", "s", "code", "") if err == nil { t.Fatal("网络故障应返回 error") } if !IsTokenError(err) { t.Fatalf("应为 *errToken,实际 %T", err) } } // TestBuildQRConnectURL 扫码授权 URL 参数完整 + 尾段 #wechat_redirect(微信约定必须保留)。 func TestBuildQRConnectURL(t *testing.T) { u := BuildQRConnectURL("appid-a", "https://cb.test/auth/wechat/callback", "state-123") // 必含关键参数(redirect_uri 已 URL-encode,故用解码后断言) if !strings.Contains(u, "appid=appid-a") { t.Fatalf("缺 appid:%q", u) } if !strings.Contains(u, "response_type=code") { t.Fatalf("缺 response_type=code:%q", u) } if !strings.Contains(u, "scope=snsapi_login") { t.Fatalf("缺 scope=snsapi_login(网页扫码登录固定):%q", u) } if !strings.Contains(u, "state=state-123") { t.Fatalf("缺 state(防 CSRF):%q", u) } if !strings.HasSuffix(u, "#wechat_redirect") { t.Fatalf("缺 #wechat_redirect 尾段(微信约定必须保留):%q", u) } // redirect_uri 应出现(URL-encoded 形式) re := regexp.MustCompile(`redirect_uri=https%3A%2F%2Fcb\.test%2Fauth%2Fwechat%2Fcallback`) if !re.MatchString(u) { t.Fatalf("redirect_uri 未正确 URL-encode:%q", u) } } // TestBuildAuthorizeURL_Userinfo 移动 webview 授权 URL:oauth2/authorize + snsapi_userinfo + 无 #wechat_redirect。 func TestBuildAuthorizeURL_Userinfo(t *testing.T) { u := BuildAuthorizeURL("appid-a", "https://cb.test/auth/wechat/callback", "state-456", ScopeUserinfo) if !strings.Contains(u, "https://open.weixin.qq.com/connect/oauth2/authorize?") { t.Fatalf("endpoint 应为 oauth2/authorize:%q", u) } if !strings.Contains(u, "appid=appid-a") { t.Fatalf("缺 appid:%q", u) } if !strings.Contains(u, "scope=snsapi_userinfo") { t.Fatalf("scope 应为 snsapi_userinfo:%q", u) } if !strings.Contains(u, "state=state-456") { t.Fatalf("缺 state(防 CSRF):%q", u) } if !strings.Contains(u, "response_type=code") { t.Fatalf("缺 response_type=code:%q", u) } // webview 场景无 #wechat_redirect 尾段 if strings.Contains(u, "#wechat_redirect") { t.Fatalf("webview 场景不应含 #wechat_redirect:%q", u) } // redirect_uri 应出现(URL-encoded 形式) re := regexp.MustCompile(`redirect_uri=https%3A%2F%2Fcb\.test%2Fauth%2Fwechat%2Fcallback`) if !re.MatchString(u) { t.Fatalf("redirect_uri 未正确 URL-encode:%q", u) } } // TestBuildAuthorizeURL_Login PC 扫码走 BuildAuthorizeURL(ScopeLogin) 应与 BuildQRConnectURL 等价。 func TestBuildAuthorizeURL_Login(t *testing.T) { u1 := BuildAuthorizeURL("appid-a", "https://cb.test/x", "s1", ScopeLogin) u2 := BuildQRConnectURL("appid-a", "https://cb.test/x", "s1") if u1 != u2 { t.Fatalf("BuildAuthorizeURL(ScopeLogin) 应等于 BuildQRConnectURL:\n A=%q\n B=%q", u1, u2) } }