Files
cmautobuy/admin/syb/client_test.go
T
chengmaandClaude Opus 5 5e426cacf6 feat: 顺运宝货运单同步 (#46)
顺运宝模块此前是骨架,「同步」点了提示"待接入"。5195 个蝦皮商品已经
进系统,但货运单(真实订单)一条都没有,后面的规格匹配无从谈起。

按接口契约(docs/admin/08,从 4 份 HAR 还原)实现:配置、登录(界面
手工输验证码)、会话缓存到 SQLite、按日期范围增量同步、落 syb_orders。

shopee_sku_id 绝不被同步覆盖。它是规格匹配的结果,顺运宝那边根本没有
这个值(只给 11 位商品ID,蝦皮規格ID 是 12 位)。同步写进去就是写空,
把人工攒的匹配成果洗掉且不报错。它只出现在 INSERT 列清单里,不在
DO UPDATE SET 里;repository 层和 service 端到端各有一个测试守着。

增量从「上次同步日期当天」重拉,不是第二天。created 筛选粒度是日期而
last_synced_at 精确到秒,从第二天拉会漏掉当天晚些时候创建的单且不报错。
宁可重复拉(upsert 幂等)也不能漏。中途失败不更新 last_synced_at,
否则下次跳过这段区间,漏的单永远补不回来。

日期运算用 UTC+8,不是 UTC。审查时从 HAR 确认 created 是当地时间:
抓包于 2026-07-28T03:31:45Z(= 11:31 UTC+8),同一响应里 created 是
"2026-07-28 10:37:59";若它是 UTC 则等于 18:37 UTC+8,比抓包晚 7 小时,
订单创建于未来,不成立。用 UTC 算会在本地 00:00-08:00 把"今天"算成昨天,
当天早晨的单这轮拉不到。用 time.FixedZone 写死,不用 LoadLocation——
那要读系统 tzdata,Windows 默认没有,打包成 exe 会失败。

金额一律取 detail/listByStock 的值:08 §5.1 实测同一响应里 amtOrder
在列表接口是分、escrowAmount 却不是,单位不统一,取错差 100 倍。

迁移 v5 纯追加(syb_session、syb_sync_state、syb_orders.product_spec),
v1-v4 逐字未动,CheckSchema 覆盖新表新列。

会话有效性判断把「网络故障」和「明确未登录」的分类集中在 Client.do()
一处——网络抖一下就判定登出的话,验证码会弹个不停,还会丢掉有效会话。

测试全部用 httptest 假服务端,不打真实站点。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:49:13 +08:00

424 lines
15 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"
"net/http"
"net/http/httptest"
"strings"
"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{
"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, err := c.ListPage(context.Background(), "2026-07-25", "2026-07-28", 0, 1, 20)
if err != nil {
t.Fatalf("list 失败: %v", err)
}
if len(rows) != 1 || rows[0].ID != 75104587 || rows[0].Code != "260728TB95MJTQ" {
t.Fatalf("列表结果不对: %+v", rows)
}
}
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_业务失败但不是登录问题时返回普通错误(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)
}
}