Files
cmautobuy/admin/syb/client_test.go
T

782 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package syb
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"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{
"total": 1,
"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)
}
rows, pageTotal, err := c.ListPage(context.Background(), "2026-07-25", "2026-07-28", 0, 1, 20)
if err != nil {
t.Fatalf("list 失败: %v", err)
}
if pageTotal != 1 {
t.Fatalf("list 响应内当前页条数应该是 1,实际 %d", pageTotal)
}
if len(rows) != 1 || rows[0].ID != 75104587 || rows[0].Code != "260728TB95MJTQ" {
t.Fatalf("列表结果不对: %+v", rows)
}
}
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}},
{"total与list长度不一致", map[string]any{"list": []any{map[string]any{"id": 1}}, "total": 0}},
} {
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 应返回错误")
}
})
}
}
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 应该报错,不应该真的发请求")
}
}
func TestClient_InnerCodeWrite_聚合码参数和路径正确且只发送一次(t *testing.T) {
targetCode := "DK260815A160101,DK260815A160102"
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") != targetCode {
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, " "+targetCode+" "); 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())
}
}
// ── 业务失败但不是登录问题 ──────────────────────────────
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)
}
}
// ── 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 时应该给出降级原因")
}
}