Files
cmautobuy/admin/service/syb_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

551 lines
18 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 service
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmautobuy/admin/config"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/syb"
)
// ── 增量边界:本工单最容易写错的地方 ─────────────────────
func TestSyncDateRange_首次同步用配置的SyncFrom(t *testing.T) {
now := time.Date(2026, 8, 9, 15, 0, 0, 0, time.UTC)
from, to, err := syncDateRange("", "2026-07-01", now)
if err != nil {
t.Fatalf("计算日期范围失败: %v", err)
}
if from != "2026-07-01" {
t.Errorf("首次同步应该用 sync_from,实际 from=%q", from)
}
if to != "2026-08-09" {
t.Errorf("结束日期应该是今天,实际 to=%q", to)
}
}
func TestSyncDateRange_首次同步且未配置SyncFrom时报错(t *testing.T) {
_, _, err := syncDateRange("", "", time.Now())
if err == nil {
t.Fatal("从未同步过又没配置 sync_from 时应该报错,而不是拿一个空日期硬拉")
}
}
func TestSyncDateRange_增量从上次同步日期当天重拉不是第二天(t *testing.T) {
// `[必须]` 这是工单 #46 明确点名"最容易写错的地方":
// 上次同步 2026-08-09 14:30,下次必须还从 2026-08-09 开始拉,
// 不能从 2026-08-10 开始——否则会漏掉 8-09 14:30 之后创建的单,
// 而且不会报错,没人会发现。
lastSyncedAt := "2026-08-09T14:30:00Z"
now := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
from, to, err := syncDateRange(lastSyncedAt, "2026-07-01", now)
if err != nil {
t.Fatalf("计算日期范围失败: %v", err)
}
if from != "2026-08-09" {
t.Fatalf("增量同步应该从上次同步的当天(2026-08-09)重新拉,实际 from=%q"+
"——如果这里算成了 2026-08-10,就是漏单且不报错的那个坑", from)
}
if to != "2026-08-10" {
t.Errorf("结束日期应该是 now 对应的日期,实际 to=%q", to)
}
}
func TestSyncDateRange_上次同步时间格式错误时报错(t *testing.T) {
_, _, err := syncDateRange("不是一个合法的时间", "2026-07-01", time.Now())
if err == nil {
t.Fatal("last_synced_at 解析失败时应该报错,不能悄悄退化成一个随便的日期")
}
}
func TestSyncDateRange_结束日期按顺运宝服务端时区UTC加8算不是UTC(t *testing.T) {
// `[必须]` 顺运宝 created 是 UTC+8(08 §5.2 有 HAR 实测证据)。
// now = 2026-08-09T23:00:00Z,也就是本地(UTC+8)2026-08-10 07:00——
// 如果日期运算错误地用了 UTC,会把"今天"算成 2026-08-09,
// 当天早晨(UTC+8)创建的单这一轮就拉不到,操作员会以为同步坏了。
now := time.Date(2026, 8, 9, 23, 0, 0, 0, time.UTC)
_, to, err := syncDateRange("", "2026-07-01", now)
if err != nil {
t.Fatalf("计算日期范围失败: %v", err)
}
if to != "2026-08-10" {
t.Fatalf("结束日期应该按顺运宝服务端时区(UTC+8)算成 2026-08-10,实际 to=%q"+
"——如果这里算成了 2026-08-09,就是用错了 UTC 而不是 UTC+8", to)
}
}
// ── 金额换算 ──────────────────────────────────────────
func TestRoundYuanToCent_先四舍五入再转整数(t *testing.T) {
cases := []struct {
yuan float64
want int64
}{
{239.0, 23900},
{612.5, 61250},
{5.05, 505}, // 08 §5.1 明确点名的样本
{0, 0},
}
for _, c := range cases {
got := roundYuanToCent(c.yuan)
if got != c.want {
t.Errorf("roundYuanToCent(%v) = %d,期望 %d", c.yuan, got, c.want)
}
}
}
// ── 端到端:httptest 假服务端 ─────────────────────────────
//
// `[必须]` 绝不能打真实的 shunyunbaoerp.com,见工单 #46。
// fakeDetail 是假服务端里一条货运单明细行的最小描述。
type fakeDetail struct {
ID int64
ProductID int64
ProductTitle string
ProductSpec string
ProductQty int
ProductPrice float64
ProductThumb int64
}
// fakeStock 是假服务端里一张货运单。
type fakeStock struct {
ID int64
Code string
ShopName string
Receiver string // 用来验证个人信息确实没有落库
Details []fakeDetail
}
// fakeSybServer 起一个 httptest 假服务端,模拟 listTotal / list /
// detail/listByStock 三个接口,数据来自内存里的 stocks 切片。
//
// failListPageIndex:如果 > 0,/am/stock/list 请求到这一页时返回失败,
// 用来测"中途失败不更新 last_synced_at"。
func fakeSybServer(t *testing.T, stocks []fakeStock, failListPageIndex int) *httptest.Server {
t.Helper()
byID := map[int64]fakeStock{}
for _, s := range stocks {
byID[s.ID] = s
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/am/stock/listTotal":
writeEnvelope(t, w, true, "ok", len(stocks), nil)
case "/am/stock/list":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
pageIndex := int(body["pageIndex"].(float64))
length := int(body["length"].(float64))
start := int(body["start"].(float64))
if failListPageIndex > 0 && pageIndex == failListPageIndex {
writeEnvelope(t, w, false, "模拟的服务端故障", nil, "500")
return
}
end := start + length
if end > len(stocks) {
end = len(stocks)
}
var list []map[string]any
if start < len(stocks) {
for _, s := range stocks[start:end] {
list = append(list, map[string]any{
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
"receiver": s.Receiver, "orderStatus": "待出货",
})
}
}
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
case "/am/stock/detail/listByStock":
var body struct {
IDs []int64 `json:"ids"`
}
json.NewDecoder(r.Body).Decode(&body)
var list []map[string]any
for _, id := range body.IDs {
s, ok := byID[id]
if !ok {
continue
}
var details []map[string]any
for _, d := range s.Details {
details = append(details, map[string]any{
"id": d.ID, "productId": d.ProductID, "productTitle": d.ProductTitle,
"productSpec": d.ProductSpec, "productQty": d.ProductQty,
"productPrice": d.ProductPrice, "productThumb": d.ProductThumb,
})
}
list = append(list, map[string]any{
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
"receiver": s.Receiver, "details": details,
})
}
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
default:
t.Errorf("测试假服务端没有实现这个路径: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
}
func writeEnvelope(t *testing.T, w http.ResponseWriter, status bool, msg string, data any, code any) {
t.Helper()
b, err := json.Marshal(map[string]any{"status": status, "msg": msg, "data": data, "code": code})
if err != nil {
t.Fatalf("构造响应失败: %v", err)
}
w.Write(b)
}
func newSyncTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := repository.Open(t.TempDir())
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := repository.Migrate(db); err != nil {
t.Fatalf("迁移失败: %v", err)
}
return db
}
func TestRunSybSync_已有的ShopeeSKUID同步后仍在(t *testing.T) {
// `[必须]` 端到端版本:整条 RunSybSync 流程跑完,人工匹配的
// shopee_sku_id 必须还在——这是工单 #46 唯一"错了要几周后才发现"的点。
srv := fakeSybServer(t, []fakeStock{
{
ID: 75104587, Code: "260728TB95MJTQ", ShopName: "测试店铺", Receiver: "张三",
Details: []fakeDetail{
{ID: 145306175, ProductID: 50209124255, ProductTitle: "蕾絲花邊拼接背心女",
ProductSpec: "白色,L【建議50-60公斤】", ProductQty: 1, ProductPrice: 239.0, ProductThumb: 190639637},
},
},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, err := syb.New(srv.URL)
if err != nil {
t.Fatalf("创建客户端失败: %v", err)
}
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report1 := RunSybSync(context.Background(), db, client, cfg, now)
if report1.Err != nil {
t.Fatalf("首次同步失败: %v", report1.Err)
}
if report1.Created != 1 {
t.Fatalf("首次同步应该新增 1 条,实际 Created=%d", report1.Created)
}
// 操作员在界面上完成规格匹配,手工写入 shopee_sku_id。
if _, err := db.Exec(`UPDATE syb_orders SET shopee_sku_id = ? WHERE syb_id = ?`,
"MANUAL-MATCHED-SKU", "145306175"); err != nil {
t.Fatalf("模拟人工匹配失败: %v", err)
}
// 再同步一次(模拟顺运宝那边这张单信息有更新)。
report2 := RunSybSync(context.Background(), db, client, cfg, now.Add(time.Hour))
if report2.Err != nil {
t.Fatalf("二次同步失败: %v", report2.Err)
}
if report2.Updated != 1 {
t.Fatalf("二次同步应该是更新,实际 Updated=%d Created=%d", report2.Updated, report2.Created)
}
var skuID string
if err := db.QueryRow(`SELECT shopee_sku_id FROM syb_orders WHERE syb_id = ?`,
"145306175").Scan(&skuID); err != nil {
t.Fatalf("查询失败: %v", err)
}
if skuID != "MANUAL-MATCHED-SKU" {
t.Fatalf("同步后 shopee_sku_id 应该还是 MANUAL-MATCHED-SKU,实际 %q"+
"——人工匹配成果被顺运宝同步洗掉了", skuID)
}
// 收件人信息不应该出现在 syb_data 里。
var sybData string
if err := db.QueryRow(`SELECT syb_data FROM syb_orders WHERE syb_id = ?`,
"145306175").Scan(&sybData); err != nil {
t.Fatalf("查询失败: %v", err)
}
if strings.Contains(sybData, "张三") {
t.Errorf("syb_data 不应该包含收件人姓名,实际: %s", sybData)
}
// 价格换算:239.0 元 -> 23900 分。
var priceCent int64
if err := db.QueryRow(`SELECT price_twd_cent FROM syb_orders WHERE syb_id = ?`,
"145306175").Scan(&priceCent); err != nil {
t.Fatalf("查询失败: %v", err)
}
if priceCent != 23900 {
t.Errorf("price_twd_cent 应该是 23900,实际 %d", priceCent)
}
// image_url 拼接。
var imageURL string
if err := db.QueryRow(`SELECT image_url FROM syb_orders WHERE syb_id = ?`,
"145306175").Scan(&imageURL); err != nil {
t.Fatalf("查询失败: %v", err)
}
wantImage := srv.URL + "/api/p/file?id=190639637"
if imageURL != wantImage {
t.Errorf("image_url 应该是 %q,实际 %q", wantImage, imageURL)
}
}
func TestRunSybSync_中途失败不更新last_synced_at(t *testing.T) {
// 两页数据,pageSize=1,第二页请求失败——模拟"拉到一半服务端出错"。
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A", Details: []fakeDetail{{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 1}}},
{ID: 2, Code: "B", Details: []fakeDetail{{ID: 2, ProductID: 222, ProductQty: 1, ProductPrice: 1}}},
}, 2) // 第 2 页失败
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 1, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err == nil {
t.Fatal("第二页失败时同步应该报错")
}
_, found, err := repository.GetSybLastSyncedAt(db)
if err != nil {
t.Fatalf("查询同步进度失败: %v", err)
}
if found {
t.Fatal("中途失败不应该更新 last_synced_at——更新了的话下次同步会跳过这段区间," +
"漏掉的单永远补不回来")
}
}
func TestRunSybSync_全部成功后更新last_synced_at(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A", Details: []fakeDetail{{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 1}}},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err != nil {
t.Fatalf("同步失败: %v", report.Err)
}
at, found, err := repository.GetSybLastSyncedAt(db)
if err != nil {
t.Fatalf("查询同步进度失败: %v", err)
}
if !found || at == "" {
t.Fatal("全部成功后应该更新 last_synced_at")
}
}
func TestRunSybSync_数量小于等于0被跳过并计入报告(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A", Details: []fakeDetail{
{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 10},
{ID: 2, ProductID: 222, ProductQty: 0, ProductPrice: 10}, // 应该被跳过
{ID: 3, ProductID: 333, ProductQty: -1, ProductPrice: 10}, // 应该被跳过
}},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err != nil {
t.Fatalf("同步失败: %v", report.Err)
}
if report.SkippedZero != 2 {
t.Fatalf("应该跳过 2 条 quantity<=0 的明细,实际 SkippedZero=%d", report.SkippedZero)
}
if len(report.Notes) != 2 {
t.Fatalf("跳过的明细应该在报告里列出来,实际 Notes=%v", report.Notes)
}
if report.Created != 1 {
t.Fatalf("只有 1 条应该真正写库,实际 Created=%d", report.Created)
}
n, err := repository.CountSybOrdersTotal(db)
if err != nil {
t.Fatalf("统计失败: %v", err)
}
if n != 1 {
t.Fatalf("库里应该只有 1 行,实际 %d 行", n)
}
}
func TestRunSybSync_超过最大条数报错不硬拉(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A"}, {ID: 2, Code: "B"}, {ID: 3, Code: "C"},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 2, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err == nil {
t.Fatal("总数 3 超过 max_matches=2 时应该报错")
}
if !strings.Contains(report.Err.Error(), "缩小") {
t.Errorf("错误信息应该提示缩小日期范围,实际: %v", report.Err)
}
if _, found, _ := repository.GetSybLastSyncedAt(db); found {
t.Error("超限报错不应该更新 last_synced_at")
}
n, _ := repository.CountSybOrdersTotal(db)
if n != 0 {
t.Errorf("超限报错不应该写入任何数据,实际写了 %d 行", n)
}
}
func TestRunSybSync_一张货运单多个商品各落一行(t *testing.T) {
srv := fakeSybServer(t, []fakeStock{
{ID: 1, Code: "A", Details: []fakeDetail{
{ID: 1, ProductID: 111, ProductQty: 1, ProductPrice: 10, ProductSpec: "白色,L"},
{ID: 2, ProductID: 222, ProductQty: 2, ProductPrice: 20, ProductSpec: "黑色,M"},
}},
}, 0)
defer srv.Close()
db := newSyncTestDB(t)
client, _ := syb.New(srv.URL)
cfg := config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"}
now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
report := RunSybSync(context.Background(), db, client, cfg, now)
if report.Err != nil {
t.Fatalf("同步失败: %v", report.Err)
}
if report.Created != 2 {
t.Fatalf("一张货运单两个商品应该各落一行,实际 Created=%d", report.Created)
}
n, _ := repository.CountSybOrdersTotal(db)
if n != 2 {
t.Fatalf("库里应该有 2 行,实际 %d 行", n)
}
}
// ── 同步互斥标志 ──────────────────────────────────────
func TestSybSyncMutex_同一时间只允许一个同步(t *testing.T) {
t.Cleanup(func() { FinishSybSync(SyncReport{}) })
if !TryStartSybSync() {
t.Fatal("第一次应该能拿到互斥标志")
}
if TryStartSybSync() {
t.Fatal("同步进行中时,第二次不应该能拿到互斥标志")
}
FinishSybSync(SyncReport{From: "2026-08-09", To: "2026-08-09"})
if !TryStartSybSync() {
t.Fatal("上一次同步结束后应该能重新拿到互斥标志")
}
status := GetSybSyncStatus()
if !status.Running {
t.Error("刚拿到互斥标志后 Running 应该是 true")
}
FinishSybSync(SyncReport{From: "2026-08-09", To: "2026-08-09"})
status = GetSybSyncStatus()
if status.Running {
t.Error("FinishSybSync 之后 Running 应该是 false")
}
if status.Report == nil || status.Report.From != "2026-08-09" {
t.Errorf("应该能读到最近一次的报告,实际: %+v", status.Report)
}
}
// ── 会话有效性判断 ────────────────────────────────────
func TestEnsureSybSession_没有缓存时要求登录(t *testing.T) {
db := newSyncTestDB(t)
client, _ := syb.New("https://example.invalid")
err := EnsureSybSession(db, client, "tester", time.Now())
if err != ErrSybLoginRequired {
t.Fatalf("没有缓存的会话时应该返回 ErrSybLoginRequired,实际: %v", err)
}
}
func TestEnsureSybSession_已过期时要求登录(t *testing.T) {
db := newSyncTestDB(t)
if err := repository.SaveSybSession(db, "tester", `[]`, "2026-08-01T00:00:00Z"); err != nil {
t.Fatalf("保存会话失败: %v", err)
}
client, _ := syb.New("https://example.invalid")
now := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
err := EnsureSybSession(db, client, "tester", now)
if err != ErrSybLoginRequired {
t.Fatalf("过期会话应该返回 ErrSybLoginRequired,实际: %v", err)
}
}
func TestEnsureSybSession_未过期时恢复Cookie不要求登录(t *testing.T) {
db := newSyncTestDB(t)
cookiesJSON := `[{"name":"erp_session","value":"abc"}]`
if err := repository.SaveSybSession(db, "tester", cookiesJSON, "2026-08-10T00:00:00Z"); err != nil {
t.Fatalf("保存会话失败: %v", err)
}
client, _ := syb.New("https://example.invalid")
now := time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC)
if err := EnsureSybSession(db, client, "tester", now); err != nil {
t.Fatalf("未过期的会话不应该要求重新登录: %v", err)
}
}
func TestSaveSybLoginSession_写库和读回(t *testing.T) {
db := newSyncTestDB(t)
client, _ := syb.New("https://example.invalid")
expiresAt := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC)
if err := SaveSybLoginSession(db, client, "tester", expiresAt); err != nil {
t.Fatalf("保存登录会话失败: %v", err)
}
cached, err := repository.GetSybSession(db, "tester")
if err != nil || cached == nil {
t.Fatalf("应该能读到刚保存的会话: cached=%v err=%v", cached, err)
}
got, ok := model.ParseISO(cached.ExpiresAt)
if !ok || !got.Equal(expiresAt) {
t.Errorf("expires_at 应该是 %v,实际 %v(parsed=%v)", expiresAt, cached.ExpiresAt, got)
}
}