2026-08-09 11:49:13 +08:00
|
|
|
|
package syb
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-08-09 12:35:02 +08:00
|
|
|
|
"io"
|
2026-08-09 11:49:13 +08:00
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
|
"strings"
|
2026-08-09 12:35:02 +08:00
|
|
|
|
"sync"
|
|
|
|
|
|
"sync/atomic"
|
2026-08-09 11:49:13 +08:00
|
|
|
|
"testing"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// `[必须]` 本文件全部用 httptest 起假服务端,绝不能打真实的
|
|
|
|
|
|
// shunyunbaoerp.com——打真站会污染对方数据、可能触发风控,见工单 #46。
|
|
|
|
|
|
|
|
|
|
|
|
// fakeJWT 造一个"看起来像"顺运宝 JWT 的 token:header.payload.signature,
|
|
|
|
|
|
// payload 是 base64url({"exp":...}),测试只关心 exp 能不能被正确解析出来。
|
|
|
|
|
|
func fakeJWT(t *testing.T, exp int64) string {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
payload := fmt.Sprintf(`{"authLogin":false,"exp":%d,"iat":%d,"username":"tester"}`, exp, exp-86400)
|
|
|
|
|
|
seg := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(payload))
|
|
|
|
|
|
return "header." + seg + ".signature"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func envelopeBody(t *testing.T, status bool, msg string, data any, code any) []byte {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
b, err := json.Marshal(map[string]any{
|
|
|
|
|
|
"status": status, "msg": msg, "data": data, "code": code,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("构造响应体失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return b
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 验证码 + 登录:同一 Cookie Jar ──────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_验证码和登录用同一个CookieJar(t *testing.T) {
|
|
|
|
|
|
var captchaCookieSeen, loginCookieSeen bool
|
|
|
|
|
|
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case r.URL.Path == "/api/p/code1":
|
|
|
|
|
|
// 验证码接口种一个会话 Cookie。
|
|
|
|
|
|
http.SetCookie(w, &http.Cookie{Name: "erp_session", Value: "abc123", Path: "/"})
|
|
|
|
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
|
|
|
|
w.Write([]byte("fake-jpeg-bytes"))
|
|
|
|
|
|
case r.URL.Path == "/am/auth/login":
|
|
|
|
|
|
// 登录请求必须带上验证码接口种下的 Cookie,
|
|
|
|
|
|
// 证明两次请求走的是同一个 Cookie Jar。
|
|
|
|
|
|
if ck, err := r.Cookie("erp_session"); err == nil && ck.Value == "abc123" {
|
|
|
|
|
|
loginCookieSeen = true
|
|
|
|
|
|
}
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "登录成功", map[string]any{
|
|
|
|
|
|
"user": map[string]any{"id": 1001, "username": "tester"},
|
|
|
|
|
|
"token": fakeJWT(t, time.Now().Add(2*time.Hour).Unix()),
|
|
|
|
|
|
}, nil))
|
|
|
|
|
|
}
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, err := New(srv.URL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("创建客户端失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
cap, err := c.FetchCaptcha(context.Background())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("获取验证码失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(cap.Image) == 0 || cap.ContentType != "image/jpeg" {
|
|
|
|
|
|
t.Fatalf("验证码内容不对: %+v", cap)
|
|
|
|
|
|
}
|
|
|
|
|
|
captchaCookieSeen = true // 只要走到这里说明请求成功了
|
|
|
|
|
|
|
|
|
|
|
|
result, err := c.Login(context.Background(), "tester", "password123", "AB12")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("登录失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !captchaCookieSeen || !loginCookieSeen {
|
|
|
|
|
|
t.Fatal("验证码和登录应该用同一个 Cookie Jar,但登录请求没带上验证码接口种的 Cookie")
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.User.ID != 1001 || result.User.Username != "tester" {
|
|
|
|
|
|
t.Errorf("登录结果不对: %+v", result.User)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 会话有效期:min(JWT exp, 24h) ────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_Login_有效期取JWT剩余和24小时的较小值(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
// JWT 只剩 2 小时,应该取 2 小时,不是 24 小时。
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
|
|
|
|
|
"user": map[string]any{"id": 1, "username": "tester"},
|
|
|
|
|
|
"token": fakeJWT(t, time.Now().Add(2*time.Hour).Unix()),
|
|
|
|
|
|
}, nil))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
result, err := c.Login(context.Background(), "tester", "pw", "code")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("登录失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
remain := time.Until(result.ExpiresAt)
|
|
|
|
|
|
if remain > 3*time.Hour || remain < time.Hour {
|
|
|
|
|
|
t.Errorf("有效期应该接近 JWT 剩余的 2 小时,实际剩 %v", remain)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_Login_JWT解析失败时退化成24小时(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
|
|
|
|
|
"user": map[string]any{"id": 1, "username": "tester"},
|
|
|
|
|
|
"token": "不是一个合法的JWT",
|
|
|
|
|
|
}, nil))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
result, err := c.Login(context.Background(), "tester", "pw", "code")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("登录失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
remain := time.Until(result.ExpiresAt)
|
|
|
|
|
|
if remain > 25*time.Hour || remain < 23*time.Hour {
|
|
|
|
|
|
t.Errorf("JWT 解析失败时应该退化成 24 小时,实际剩 %v", remain)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── §3.5:区分"明确未登录"和"网络故障" ───────────────────
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_CheckSession_HTTP401判定为未登录(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
err := c.CheckSession(context.Background(), 1001, "tester")
|
|
|
|
|
|
if !errors.Is(err, ErrSessionInvalid) {
|
|
|
|
|
|
t.Fatalf("HTTP 401 应该判定为未登录,实际: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_CheckSession_业务码未登录判定为未登录(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.Write(envelopeBody(t, false, "登录过期,请重新登录", nil, "-2"))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
err := c.CheckSession(context.Background(), 1001, "tester")
|
|
|
|
|
|
if !errors.Is(err, ErrSessionInvalid) {
|
|
|
|
|
|
t.Fatalf("msg 含「登录过期」应该判定为未登录,实际: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_CheckSession_id或username不一致判定为未登录(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "ok", map[string]any{"id": 9999, "username": "别人"}, nil))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
err := c.CheckSession(context.Background(), 1001, "tester")
|
|
|
|
|
|
if !errors.Is(err, ErrSessionInvalid) {
|
|
|
|
|
|
t.Fatalf("id/username 不一致(串号)应该判定为未登录,实际: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_CheckSession_超时不判定为未登录(t *testing.T) {
|
|
|
|
|
|
// `[必须]` 08 §3.5 最重要的一条:网络故障不能被误判成"未登录",
|
|
|
|
|
|
// 否则网络抖一下就会触发重新登录、弹验证码,还可能把有效会话丢掉。
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "ok", map[string]any{"id": 1001, "username": "tester"}, nil))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
c.http.Timeout = 50 * time.Millisecond // 故意设一个比服务端延迟短的超时
|
|
|
|
|
|
|
|
|
|
|
|
err := c.CheckSession(context.Background(), 1001, "tester")
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("超时应该返回错误")
|
|
|
|
|
|
}
|
|
|
|
|
|
if errors.Is(err, ErrSessionInvalid) {
|
|
|
|
|
|
t.Fatalf("超时不能被判定为「未登录」,实际: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_CheckSession_HTTP5xx不判定为未登录(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
err := c.CheckSession(context.Background(), 1001, "tester")
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("5xx 应该返回错误")
|
|
|
|
|
|
}
|
|
|
|
|
|
if errors.Is(err, ErrSessionInvalid) {
|
|
|
|
|
|
t.Fatalf("5xx(服务端故障)不能被判定为「未登录」,实际: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_CheckSession_响应格式错误不判定为未登录(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.Write([]byte("这不是 JSON"))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
err := c.CheckSession(context.Background(), 1001, "tester")
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("格式错误应该返回错误")
|
|
|
|
|
|
}
|
|
|
|
|
|
if errors.Is(err, ErrSessionInvalid) {
|
|
|
|
|
|
t.Fatalf("响应格式错误不能被判定为「未登录」,实际: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_CheckSession_会话有效时返回nil(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "ok", map[string]any{"id": 1001, "username": "tester"}, nil))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
if err := c.CheckSession(context.Background(), 1001, "tester"); err != nil {
|
|
|
|
|
|
t.Fatalf("会话有效时应该返回 nil,实际: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Cookie 持久化:导出 → 导入 ──────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_Cookie导出后可以导入到新客户端(t *testing.T) {
|
|
|
|
|
|
var seenCookieValue string
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if r.URL.Path == "/set" {
|
|
|
|
|
|
http.SetCookie(w, &http.Cookie{Name: "erp_session", Value: "the-cookie-value", Path: "/"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if ck, err := r.Cookie("erp_session"); err == nil {
|
|
|
|
|
|
seenCookieValue = ck.Value
|
|
|
|
|
|
}
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c1, _ := New(srv.URL)
|
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/set", nil)
|
|
|
|
|
|
resp, err := c1.http.Do(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("请求失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
cookiesJSON, err := c1.ExportCookiesJSON()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("导出 Cookie 失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(cookiesJSON, "the-cookie-value") {
|
|
|
|
|
|
t.Fatalf("导出的 Cookie JSON 应该包含 Cookie 的值,实际: %s", cookiesJSON)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 新客户端(模拟重启 Admin 后新建的 Client),导入缓存的 Cookie。
|
|
|
|
|
|
c2, _ := New(srv.URL)
|
|
|
|
|
|
if err := c2.ImportCookiesJSON(cookiesJSON); err != nil {
|
|
|
|
|
|
t.Fatalf("导入 Cookie 失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c2.CheckSession(context.Background(), 1, "x"); err != nil && !errors.Is(err, ErrSessionInvalid) {
|
|
|
|
|
|
// 忽略——这里只是想借这个请求确认 Cookie 被带上了,不关心业务结果
|
|
|
|
|
|
}
|
|
|
|
|
|
if seenCookieValue != "the-cookie-value" {
|
|
|
|
|
|
t.Fatalf("新客户端应该带上导入的 Cookie 发请求,实际服务端看到的值: %q", seenCookieValue)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 货运单列表 + 明细 ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_ListTotal和ListPage(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
var body map[string]any
|
|
|
|
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
|
|
queries, _ := body["queries"].([]any)
|
|
|
|
|
|
if len(queries) != 1 {
|
|
|
|
|
|
t.Errorf("queries 应该有 1 个条件,实际 %d 个", len(queries))
|
|
|
|
|
|
}
|
|
|
|
|
|
q := queries[0].(map[string]any)
|
|
|
|
|
|
if q["dvalue"] != "2026-07-25,2026-07-28" {
|
|
|
|
|
|
t.Errorf("dvalue 拼接不对: %v", q["dvalue"])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
|
case "/am/stock/listTotal":
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "ok", 1, nil))
|
|
|
|
|
|
case "/am/stock/list":
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
2026-08-09 17:17:42 +08:00
|
|
|
|
"total": 1,
|
2026-08-09 11:49:13 +08:00
|
|
|
|
"list": []map[string]any{
|
|
|
|
|
|
{"id": 75104587, "code": "260728TB95MJTQ", "amtOrder": 61200},
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil))
|
|
|
|
|
|
}
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
total, err := c.ListTotal(context.Background(), "2026-07-25", "2026-07-28", 20)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("listTotal 失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if total != 1 {
|
|
|
|
|
|
t.Fatalf("总数应该是 1,实际 %d", total)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 17:17:42 +08:00
|
|
|
|
rows, pageTotal, err := c.ListPage(context.Background(), "2026-07-25", "2026-07-28", 0, 1, 20)
|
2026-08-09 11:49:13 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("list 失败: %v", err)
|
|
|
|
|
|
}
|
2026-08-09 17:17:42 +08:00
|
|
|
|
if pageTotal != 1 {
|
2026-08-09 19:01:04 +08:00
|
|
|
|
t.Fatalf("list 响应内当前页条数应该是 1,实际 %d", pageTotal)
|
2026-08-09 17:17:42 +08:00
|
|
|
|
}
|
2026-08-09 11:49:13 +08:00
|
|
|
|
if len(rows) != 1 || rows[0].ID != 75104587 || rows[0].Code != "260728TB95MJTQ" {
|
|
|
|
|
|
t.Fatalf("列表结果不对: %+v", rows)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 17:17:42 +08:00
|
|
|
|
func TestClient_ListPage要求合法Total(t *testing.T) {
|
|
|
|
|
|
for _, test := range []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
data map[string]any
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"缺少total", map[string]any{"list": []any{}}},
|
|
|
|
|
|
{"total类型错误", map[string]any{"list": []any{}, "total": "1"}},
|
|
|
|
|
|
{"total为负数", map[string]any{"list": []any{}, "total": -1}},
|
2026-08-09 19:01:04 +08:00
|
|
|
|
{"total与list长度不一致", map[string]any{"list": []any{map[string]any{"id": 1}}, "total": 0}},
|
2026-08-09 17:17:42 +08:00
|
|
|
|
} {
|
|
|
|
|
|
t.Run(test.name, func(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "ok", test.data, nil))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
client, _ := New(srv.URL)
|
|
|
|
|
|
if _, _, err := client.ListPage(context.Background(), "2026-08-09", "2026-08-09", 0, 1, 20); err == nil {
|
|
|
|
|
|
t.Fatal("非法 total 应返回错误")
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 11:49:13 +08:00
|
|
|
|
func TestClient_DetailListByStock_一单多商品(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if r.URL.Query().Get("hist") != "0" {
|
|
|
|
|
|
t.Errorf("hist 参数应该是 0,实际 %q", r.URL.Query().Get("hist"))
|
|
|
|
|
|
}
|
|
|
|
|
|
var body map[string]any
|
|
|
|
|
|
json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
|
|
ids, _ := body["ids"].([]any)
|
|
|
|
|
|
if len(ids) != 1 || ids[0].(float64) != 75104587 {
|
|
|
|
|
|
t.Errorf("ids 传递不对: %v", ids)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "ok", map[string]any{
|
|
|
|
|
|
"list": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": 75104587, "code": "260728TB95MJTQ", "shopName": "测试店铺",
|
|
|
|
|
|
"amtOrder": 612.0,
|
|
|
|
|
|
"details": []map[string]any{
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": 145306175, "productId": 50209124255,
|
|
|
|
|
|
"productTitle": "蕾絲花邊拼接背心女", "productSpec": "白色,L【建議50-60公斤】",
|
|
|
|
|
|
"productQty": 1, "productPrice": 239.0, "productThumb": 190639637,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"id": 145306176, "productId": 50209124256,
|
|
|
|
|
|
"productTitle": "牛仔裤", "productSpec": "黑色,M",
|
|
|
|
|
|
"productQty": 2, "productPrice": 439.0, "productThumb": 190639638,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
details, err := c.DetailListByStock(context.Background(), []int64{75104587})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("查询明细失败: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(details) != 1 {
|
|
|
|
|
|
t.Fatalf("应该有 1 张货运单,实际 %d", len(details))
|
|
|
|
|
|
}
|
|
|
|
|
|
d := details[0]
|
|
|
|
|
|
if d.ID != 75104587 || d.Code != "260728TB95MJTQ" {
|
|
|
|
|
|
t.Fatalf("外层字段不对: %+v", d)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(d.Details) != 2 {
|
|
|
|
|
|
t.Fatalf("一张货运单应该拆出 2 个商品明细,实际 %d 个", len(d.Details))
|
|
|
|
|
|
}
|
|
|
|
|
|
if d.Details[0].ProductID != 50209124255 || d.Details[0].ProductSpec != "白色,L【建議50-60公斤】" {
|
|
|
|
|
|
t.Errorf("第一个商品明细字段不对: %+v", d.Details[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
if d.Details[0].ProductPrice != 239.0 {
|
|
|
|
|
|
t.Errorf("单价应该是明细接口的原始值(元,未换算),实际 %v", d.Details[0].ProductPrice)
|
|
|
|
|
|
}
|
|
|
|
|
|
if d.Details[1].ProductID != 50209124256 || d.Details[1].ProductQty != 2 {
|
|
|
|
|
|
t.Errorf("第二个商品明细字段不对: %+v", d.Details[1])
|
|
|
|
|
|
}
|
|
|
|
|
|
// details 不应该出现在外层 Raw 里,避免落库时重复。
|
|
|
|
|
|
if _, ok := d.Raw["details"]; ok {
|
|
|
|
|
|
t.Error("StockDetail.Raw 不应该包含 details(那是嵌套结构,已经拆到 Details 字段)")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_DetailListByStock_超过100个id报错(t *testing.T) {
|
|
|
|
|
|
c, _ := New("https://example.invalid")
|
|
|
|
|
|
ids := make([]int64, 101)
|
|
|
|
|
|
_, err := c.DetailListByStock(context.Background(), ids)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("超过 100 个 id 应该报错,不应该真的发请求")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 09:10:10 +08:00
|
|
|
|
func TestClient_InnerCodeWrite_参数和路径正确且只发送一次(t *testing.T) {
|
|
|
|
|
|
var requests atomic.Int32
|
|
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
requests.Add(1)
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
|
case "/am/stock/detail/deleteInnerCode":
|
|
|
|
|
|
if r.URL.Query().Get("detailId") != "22" {
|
|
|
|
|
|
t.Errorf("delete query=%v", r.URL.Query())
|
|
|
|
|
|
}
|
|
|
|
|
|
case "/am/stock/detail/updateDetailCode":
|
|
|
|
|
|
query := r.URL.Query()
|
|
|
|
|
|
if query.Get("t") != "0" || query.Get("id") != "11" || query.Get("detailId") != "22" || query.Get("code") != "DK-001" {
|
|
|
|
|
|
t.Errorf("update query=%v", query)
|
|
|
|
|
|
}
|
|
|
|
|
|
default:
|
|
|
|
|
|
t.Errorf("意外路径 %s", r.URL.Path)
|
|
|
|
|
|
}
|
|
|
|
|
|
io.WriteString(w, `{"status":true,"msg":"成功","data":null}`)
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
client, err := New(server.URL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := client.DeleteInnerCode(context.Background(), 22); err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := client.UpdateDetailCode(context.Background(), 11, 22, " DK-001 "); err != nil {
|
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if requests.Load() != 2 {
|
|
|
|
|
|
t.Fatalf("每个写动作只能发一次请求,实际总请求 %d", requests.Load())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_InnerCodeWrite_未知结果与明确业务失败分开(t *testing.T) {
|
|
|
|
|
|
for _, tc := range []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
status int
|
|
|
|
|
|
body string
|
|
|
|
|
|
wantUnknown bool
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"服务端故障", http.StatusInternalServerError, `oops`, true},
|
|
|
|
|
|
{"成功响应损坏", http.StatusOK, `not-json`, true},
|
|
|
|
|
|
{"明确业务失败", http.StatusOK, `{"status":false,"msg":"已打单数据不能清除","code":1}`, false},
|
|
|
|
|
|
} {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.WriteHeader(tc.status)
|
|
|
|
|
|
io.WriteString(w, tc.body)
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
client, _ := New(server.URL)
|
|
|
|
|
|
err := client.DeleteInnerCode(context.Background(), 22)
|
|
|
|
|
|
if err == nil || errors.Is(err, ErrWriteResultUnknown) != tc.wantUnknown {
|
|
|
|
|
|
t.Fatalf("err=%v unknown=%v want=%v", err, errors.Is(err, ErrWriteResultUnknown), tc.wantUnknown)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_UpdateDetailCode_本地校验失败不发送请求(t *testing.T) {
|
|
|
|
|
|
var requests atomic.Int32
|
|
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
requests.Add(1)
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer server.Close()
|
|
|
|
|
|
client, _ := New(server.URL)
|
|
|
|
|
|
for _, code := range []string{"", "bad\ncode", strings.Repeat("长", 129)} {
|
|
|
|
|
|
if err := client.UpdateDetailCode(context.Background(), 1, 2, code); err == nil {
|
|
|
|
|
|
t.Errorf("code=%q 应被拒绝", code)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if requests.Load() != 0 {
|
|
|
|
|
|
t.Fatalf("本地校验失败不应发请求,实际 %d", requests.Load())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 11:49:13 +08:00
|
|
|
|
// ── 业务失败但不是登录问题 ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_业务失败但不是登录问题时返回普通错误(t *testing.T) {
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
w.Write(envelopeBody(t, false, "参数错误", nil, "400"))
|
|
|
|
|
|
}))
|
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(srv.URL)
|
|
|
|
|
|
_, err := c.ListTotal(context.Background(), "2026-01-01", "2026-01-02", 20)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("业务失败应该返回错误")
|
|
|
|
|
|
}
|
|
|
|
|
|
if errors.Is(err, ErrSessionInvalid) {
|
|
|
|
|
|
t.Fatalf("普通业务错误不应该被误判为未登录,实际: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(err.Error(), "参数错误") {
|
|
|
|
|
|
t.Errorf("错误信息应该带上服务端的 msg,实际: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-09 12:35:02 +08:00
|
|
|
|
|
|
|
|
|
|
// ── LoginWithOCR:验证码自动识别登录(工单 #47) ──────────────
|
|
|
|
|
|
//
|
|
|
|
|
|
// `[必须]` 全部用 httptest 起假的顺运宝服务端和假的 OCR 服务端,
|
|
|
|
|
|
// 绝不能打真实的 shunyunbaoerp.com 或 ocr.ilapage.cn。
|
|
|
|
|
|
|
|
|
|
|
|
// loginRecorder 记录每一次提交给 /am/auth/login 的验证码文本。
|
|
|
|
|
|
//
|
|
|
|
|
|
// `[必须]` 光断言"重试了几次"证明不了"不合格的验证码没被拿去登录"——
|
|
|
|
|
|
// 变异测试(把长度校验改成 if false)在只看重试次数的断言下依然能
|
|
|
|
|
|
// 全绿通过,因为"拿不合格的码登录失败→触发重试"和"校验不通过→
|
|
|
|
|
|
// 直接换图重试"从外部看调用次数是一样的。必须实际记下提交了什么码,
|
|
|
|
|
|
// 才能证明"ab"、""这类不合格的码**从来没有被提交过**。
|
|
|
|
|
|
type loginRecorder struct {
|
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
|
codes []string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (r *loginRecorder) record(code string) {
|
|
|
|
|
|
r.mu.Lock()
|
|
|
|
|
|
defer r.mu.Unlock()
|
|
|
|
|
|
r.codes = append(r.codes, code)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (r *loginRecorder) snapshot() []string {
|
|
|
|
|
|
r.mu.Lock()
|
|
|
|
|
|
defer r.mu.Unlock()
|
|
|
|
|
|
out := make([]string, len(r.codes))
|
|
|
|
|
|
copy(out, r.codes)
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// fakeSybServer 起一个假顺运宝服务端:验证码接口每次返回一张"新图"
|
|
|
|
|
|
// (用递增的字节内容区分,方便断言"每次重试都取了新图"),登录接口
|
|
|
|
|
|
// 按 loginCheck 决定成功还是失败,并把每次提交的验证码记进
|
|
|
|
|
|
// loginRecorder,供测试断言"不合格的码有没有被拿去登录"。
|
|
|
|
|
|
func fakeSybServer(t *testing.T, loginCheck func(code string) bool) (*httptest.Server, *int32, *loginRecorder) {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
var captchaCalls int32
|
|
|
|
|
|
rec := &loginRecorder{}
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
|
case "/api/p/code1":
|
|
|
|
|
|
n := atomic.AddInt32(&captchaCalls, 1)
|
|
|
|
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
|
|
|
|
w.Write([]byte(fmt.Sprintf("fake-jpeg-%d", n)))
|
|
|
|
|
|
case "/am/auth/login":
|
|
|
|
|
|
var body struct {
|
|
|
|
|
|
Code string `json:"code"`
|
|
|
|
|
|
}
|
|
|
|
|
|
raw, _ := io.ReadAll(r.Body)
|
|
|
|
|
|
json.Unmarshal(raw, &body)
|
|
|
|
|
|
rec.record(body.Code)
|
|
|
|
|
|
if loginCheck(body.Code) {
|
|
|
|
|
|
w.Write(envelopeBody(t, true, "登录成功", map[string]any{
|
|
|
|
|
|
"user": map[string]any{"id": 1001, "username": "tester"},
|
|
|
|
|
|
"token": fakeJWT(t, time.Now().Add(2*time.Hour).Unix()),
|
|
|
|
|
|
}, nil))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
w.Write(envelopeBody(t, false, "验证码错误", nil, "1"))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}))
|
|
|
|
|
|
return srv, &captchaCalls, rec
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// fakeOcrServer 起一个假 OCR 服务端:每次调用按顺序返回 responses 里的
|
|
|
|
|
|
// 下一个 data;responses 用完后重复最后一个。
|
|
|
|
|
|
func fakeOcrServer(t *testing.T, responses ...string) (*httptest.Server, *int32) {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
var calls int32
|
|
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
n := int(atomic.AddInt32(&calls, 1)) - 1
|
|
|
|
|
|
if n >= len(responses) {
|
|
|
|
|
|
n = len(responses) - 1
|
|
|
|
|
|
}
|
|
|
|
|
|
w.Write(ocrBody(200, "Success", responses[n]))
|
|
|
|
|
|
}))
|
|
|
|
|
|
return srv, &calls
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_LoginWithOCR_识别成功一次就登录(t *testing.T) {
|
|
|
|
|
|
sybSrv, captchaCalls, _ := fakeSybServer(t, func(code string) bool { return code == "kycv" })
|
|
|
|
|
|
defer sybSrv.Close()
|
|
|
|
|
|
ocrSrv, ocrCalls := fakeOcrServer(t, "kycv")
|
|
|
|
|
|
defer ocrSrv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(sybSrv.URL)
|
|
|
|
|
|
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
|
|
|
|
|
|
|
|
|
|
|
|
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
|
|
|
|
|
|
if reason != "" {
|
|
|
|
|
|
t.Fatalf("应该自动登录成功,不应该降级,实际 reason=%q", reason)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result == nil || result.User.Username != "tester" {
|
|
|
|
|
|
t.Fatalf("登录结果不对: %+v", result)
|
|
|
|
|
|
}
|
|
|
|
|
|
if atomic.LoadInt32(captchaCalls) != 1 || atomic.LoadInt32(ocrCalls) != 1 {
|
|
|
|
|
|
t.Errorf("一次成功不应该重试,captcha=%d ocr=%d", *captchaCalls, *ocrCalls)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_LoginWithOCR_data为空判定失败并重新取图重试(t *testing.T) {
|
|
|
|
|
|
// `[必须]` loginCheck 故意让空字符串也能"成功"——如果长度校验被
|
|
|
|
|
|
// 意外禁用/删掉,空字符串会被直接拿去登录并成功,captchaCalls 和
|
|
|
|
|
|
// reason 这两个断言就会跟着变绿,测试就抓不到这个回归。真正能
|
|
|
|
|
|
// 抓住回归的是下面对 rec.snapshot() 的断言:不管登录接口对空码
|
|
|
|
|
|
// 判不判定成功,只要空码被"提交"过一次,就说明校验没生效。
|
|
|
|
|
|
sybSrv, captchaCalls, rec := fakeSybServer(t, func(code string) bool { return true })
|
|
|
|
|
|
defer sybSrv.Close()
|
|
|
|
|
|
// 第一次 data 为空(识别失败,不是错误),第二次识别出 4 位有效码。
|
|
|
|
|
|
ocrSrv, ocrCalls := fakeOcrServer(t, "", "wxyz")
|
|
|
|
|
|
defer ocrSrv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(sybSrv.URL)
|
|
|
|
|
|
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
|
|
|
|
|
|
|
|
|
|
|
|
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
|
|
|
|
|
|
if reason != "" {
|
|
|
|
|
|
t.Fatalf("第二次应该识别成功登录,不应该降级,实际 reason=%q", reason)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result == nil {
|
|
|
|
|
|
t.Fatal("登录结果不应该为空")
|
|
|
|
|
|
}
|
|
|
|
|
|
if atomic.LoadInt32(captchaCalls) != 2 {
|
|
|
|
|
|
t.Errorf("data 为空应该重新取验证码图再试一次,captcha 调用次数应该是 2,实际 %d", *captchaCalls)
|
|
|
|
|
|
}
|
|
|
|
|
|
if atomic.LoadInt32(ocrCalls) != 2 {
|
|
|
|
|
|
t.Errorf("应该调用 OCR 两次,实际 %d", *ocrCalls)
|
|
|
|
|
|
}
|
|
|
|
|
|
codes := rec.snapshot()
|
|
|
|
|
|
if len(codes) != 1 {
|
|
|
|
|
|
t.Fatalf("空 data 不应该被拿去登录,登录接口应该只被调用 1 次(用第二次识别出的 wxyz),实际调用 %d 次: %v", len(codes), codes)
|
|
|
|
|
|
}
|
|
|
|
|
|
if codes[0] != "wxyz" {
|
|
|
|
|
|
t.Errorf("唯一一次登录提交的应该是识别成功的 wxyz,实际 %q(说明空字符串被拿去登录了)", codes[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_LoginWithOCR_长度不对判定失败并重试(t *testing.T) {
|
|
|
|
|
|
// `[必须]` loginCheck 故意让任何码都能"成功",理由同上一个测试:
|
|
|
|
|
|
// 真正能抓住"长度校验被删掉"这个回归的是 rec.snapshot() 断言,
|
|
|
|
|
|
// 不是重试次数或最终结果。
|
|
|
|
|
|
sybSrv, captchaCalls, rec := fakeSybServer(t, func(code string) bool { return true })
|
|
|
|
|
|
defer sybSrv.Close()
|
|
|
|
|
|
// 第一次只识别出 2 位,不是合法的 4 位验证码,应该换图重试。
|
|
|
|
|
|
ocrSrv, _ := fakeOcrServer(t, "ab", "wxyz")
|
|
|
|
|
|
defer ocrSrv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(sybSrv.URL)
|
|
|
|
|
|
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
|
|
|
|
|
|
|
|
|
|
|
|
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
|
|
|
|
|
|
if reason != "" {
|
|
|
|
|
|
t.Fatalf("第二次应该识别成功登录,实际 reason=%q", reason)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result == nil {
|
|
|
|
|
|
t.Fatal("登录结果不应该为空")
|
|
|
|
|
|
}
|
|
|
|
|
|
if atomic.LoadInt32(captchaCalls) != 2 {
|
|
|
|
|
|
t.Errorf("长度不对应该重新取图重试,captcha 调用次数应该是 2,实际 %d", *captchaCalls)
|
|
|
|
|
|
}
|
|
|
|
|
|
codes := rec.snapshot()
|
|
|
|
|
|
if len(codes) != 1 {
|
|
|
|
|
|
t.Fatalf("长度不对(\"ab\")不应该被拿去登录,登录接口应该只被调用 1 次(用第二次识别出的 wxyz),实际调用 %d 次: %v", len(codes), codes)
|
|
|
|
|
|
}
|
|
|
|
|
|
if codes[0] != "wxyz" {
|
|
|
|
|
|
t.Errorf("唯一一次登录提交的应该是 4 位的 wxyz,实际 %q(说明 \"ab\" 被拿去登录了)", codes[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_LoginWithOCR_过滤空格和标点后再判长度(t *testing.T) {
|
|
|
|
|
|
sybSrv, _, _ := fakeSybServer(t, func(code string) bool { return code == "ab12" })
|
|
|
|
|
|
defer sybSrv.Close()
|
|
|
|
|
|
// OCR 带回空格和标点,过滤后应该恰好是 4 位 "ab12"。
|
|
|
|
|
|
ocrSrv, _ := fakeOcrServer(t, " a-b1 2.")
|
|
|
|
|
|
defer ocrSrv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(sybSrv.URL)
|
|
|
|
|
|
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
|
|
|
|
|
|
|
|
|
|
|
|
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
|
|
|
|
|
|
if reason != "" {
|
|
|
|
|
|
t.Fatalf("过滤空格标点后应该识别成 4 位并登录成功,实际 reason=%q", reason)
|
|
|
|
|
|
}
|
|
|
|
|
|
if result == nil {
|
|
|
|
|
|
t.Fatal("登录结果不应该为空")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_LoginWithOCR_达到重试上限后降级说明次数(t *testing.T) {
|
|
|
|
|
|
sybSrv, captchaCalls, _ := fakeSybServer(t, func(code string) bool { return false })
|
|
|
|
|
|
defer sybSrv.Close()
|
|
|
|
|
|
// 每次都识别出 4 位,但登录一直失败(模拟验证码一直识别错)。
|
|
|
|
|
|
ocrSrv, _ := fakeOcrServer(t, "aaaa")
|
|
|
|
|
|
defer ocrSrv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(sybSrv.URL)
|
|
|
|
|
|
ocr, _ := NewOcrClient(ocrSrv.URL, time.Second)
|
|
|
|
|
|
|
|
|
|
|
|
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 3)
|
|
|
|
|
|
if result != nil {
|
|
|
|
|
|
t.Fatal("登录应该一直失败,不应该有结果")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(reason, "已尝试 3 次") {
|
|
|
|
|
|
t.Errorf("降级说明应该带上已尝试次数,实际: %q", reason)
|
|
|
|
|
|
}
|
|
|
|
|
|
if atomic.LoadInt32(captchaCalls) != 3 {
|
|
|
|
|
|
t.Errorf("应该恰好重试 3 次(=maxAttempts),实际 captcha 调用 %d 次", *captchaCalls)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_LoginWithOCR_OCR不可达立即降级不占满重试次数(t *testing.T) {
|
|
|
|
|
|
sybSrv, captchaCalls, _ := fakeSybServer(t, func(code string) bool { return true })
|
|
|
|
|
|
defer sybSrv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// 服务地址存在但已关闭:连不上。
|
|
|
|
|
|
deadSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
|
|
|
|
deadSrv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(sybSrv.URL)
|
|
|
|
|
|
ocr, _ := NewOcrClient(deadSrv.URL, 500*time.Millisecond)
|
|
|
|
|
|
|
|
|
|
|
|
result, reason := c.LoginWithOCR(context.Background(), ocr, "tester", "pw", 5)
|
|
|
|
|
|
if result != nil {
|
|
|
|
|
|
t.Fatal("OCR 不可达不应该登录成功")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.Contains(reason, "不可用") {
|
|
|
|
|
|
t.Errorf("降级说明应该提示服务不可用,实际: %q", reason)
|
|
|
|
|
|
}
|
|
|
|
|
|
if atomic.LoadInt32(captchaCalls) != 1 {
|
|
|
|
|
|
t.Errorf("OCR 不可达应该立即降级,不应该重试 maxAttempts 次,实际取了 %d 次验证码图", *captchaCalls)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestClient_LoginWithOCR_ocr未配置直接降级(t *testing.T) {
|
|
|
|
|
|
sybSrv, _, _ := fakeSybServer(t, func(code string) bool { return true })
|
|
|
|
|
|
defer sybSrv.Close()
|
|
|
|
|
|
|
|
|
|
|
|
c, _ := New(sybSrv.URL)
|
|
|
|
|
|
result, reason := c.LoginWithOCR(context.Background(), nil, "tester", "pw", 5)
|
|
|
|
|
|
if result != nil {
|
|
|
|
|
|
t.Fatal("ocr 为 nil 时不应该登录成功")
|
|
|
|
|
|
}
|
|
|
|
|
|
if reason == "" {
|
|
|
|
|
|
t.Fatal("ocr 为 nil 时应该给出降级原因")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|