顺运宝模块此前是骨架,「同步」点了提示"待接入"。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>
424 lines
15 KiB
Go
424 lines
15 KiB
Go
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)
|
||
}
|
||
}
|