1233 lines
44 KiB
Go
1233 lines
44 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"net/http/httputil"
|
||
"net/url"
|
||
"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 TestNewSybSyncOptions_指定日期校验(t *testing.T) {
|
||
now := time.Date(2026, 8, 9, 23, 0, 0, 0, time.UTC) // UTC+8 已是 8 月 10 日
|
||
cases := []struct {
|
||
name, from, to string
|
||
wantErr string
|
||
}{
|
||
{name: "两端都空是自动增量"},
|
||
{name: "合法闭区间", from: "2026-08-01", to: "2026-08-10"},
|
||
{name: "三十一天合法", from: "2026-07-11", to: "2026-08-10"},
|
||
{name: "超过三十一天", from: "2026-07-10", to: "2026-08-10", wantErr: "最多选择 31 天"},
|
||
{name: "只填开始", from: "2026-08-01", wantErr: "同时填写"},
|
||
{name: "开始格式错误", from: "2026/08/01", to: "2026-08-02", wantErr: "开始日期格式"},
|
||
{name: "结束格式错误", from: "2026-08-01", to: "2026/08/02", wantErr: "结束日期格式"},
|
||
{name: "起止倒置", from: "2026-08-03", to: "2026-08-02", wantErr: "不能晚于"},
|
||
{name: "晚于UTC加8今天", from: "2026-08-10", to: "2026-08-11", wantErr: "不能晚于"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got, err := NewSybSyncOptions(tc.from, tc.to, now)
|
||
if tc.wantErr == "" {
|
||
if err != nil {
|
||
t.Fatalf("不应该报错: %v", err)
|
||
}
|
||
if got.From != tc.from || got.To != tc.to {
|
||
t.Fatalf("范围被意外改变:%+v", got)
|
||
}
|
||
return
|
||
}
|
||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||
t.Fatalf("错误应包含 %q,实际 %v", tc.wantErr, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestDefaultSybSyncRange_固定为昨天到今天(t *testing.T) {
|
||
now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC)
|
||
t.Run("没有游标", func(t *testing.T) {
|
||
db := newSyncTestDB(t)
|
||
got, err := DefaultSybSyncRange(db, now)
|
||
if err != nil || got.From != "2026-08-08" || got.To != "2026-08-09" || got.Warning != "" {
|
||
t.Fatalf("默认范围错误: got=%+v err=%v", got, err)
|
||
}
|
||
})
|
||
t.Run("旧游标不改写页面默认范围", func(t *testing.T) {
|
||
db := newSyncTestDB(t)
|
||
if err := repository.SetSybLastSyncedAt(db, "2026-07-01T00:00:00Z"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got, err := DefaultSybSyncRange(db, now)
|
||
if err != nil || got.From != "2026-08-08" || got.To != "2026-08-09" || got.Warning != "" {
|
||
t.Fatalf("有旧游标时默认范围错误: got=%+v err=%v", got, err)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ── 金额换算 ──────────────────────────────────────────
|
||
|
||
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
|
||
Created 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 i := range stocks {
|
||
if stocks[i].Created == "" {
|
||
stocks[i].Created = "2026-07-28"
|
||
}
|
||
if stocks[i].ShopName == "" {
|
||
stocks[i].ShopName = "测试店铺"
|
||
}
|
||
s := stocks[i]
|
||
byID[s.ID] = s
|
||
}
|
||
|
||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/am/stock/listTotal":
|
||
var body map[string]any
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
t.Fatalf("解析 listTotal 请求失败: %v", err)
|
||
}
|
||
filtered := filterFakeStocksByRequest(t, stocks, body)
|
||
writeEnvelope(t, w, true, "ok", len(filtered), nil)
|
||
|
||
case "/am/stock/list":
|
||
var body map[string]any
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
t.Fatalf("解析 list 请求失败: %v", err)
|
||
}
|
||
filtered := filterFakeStocksByRequest(t, stocks, 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(filtered) {
|
||
end = len(filtered)
|
||
}
|
||
var list []map[string]any
|
||
if start < len(filtered) {
|
||
for _, s := range filtered[start:end] {
|
||
list = append(list, map[string]any{
|
||
"id": s.ID, "code": s.Code, "shopName": s.ShopName,
|
||
"receiver": s.Receiver, "orderStatus": "待出货",
|
||
})
|
||
}
|
||
}
|
||
// 真实 HAR 中 data.total 是当前页条数,不是筛选范围总数。
|
||
writeEnvelope(t, w, true, "ok", map[string]any{"list": list, "total": len(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 filterFakeStocksByRequest(t *testing.T, stocks []fakeStock, body map[string]any) []fakeStock {
|
||
t.Helper()
|
||
queries, ok := body["queries"].([]any)
|
||
if !ok || len(queries) == 0 {
|
||
t.Fatalf("同步请求缺少 queries: %#v", body["queries"])
|
||
}
|
||
query, ok := queries[0].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("同步请求 queries[0] 格式错误: %#v", queries[0])
|
||
}
|
||
rangeText, ok := query["dvalue"].(string)
|
||
if !ok {
|
||
t.Fatalf("同步请求缺少日期范围 dvalue: %#v", query["dvalue"])
|
||
}
|
||
parts := strings.SplitN(rangeText, ",", 2)
|
||
if len(parts) != 2 {
|
||
t.Fatalf("同步请求日期范围格式错误: %q", rangeText)
|
||
}
|
||
|
||
filtered := make([]fakeStock, 0, len(stocks))
|
||
for _, stock := range stocks {
|
||
if stock.Created >= parts[0] && stock.Created <= parts[1] {
|
||
filtered = append(filtered, stock)
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
type integrityServerData struct {
|
||
total int
|
||
afterTotal int
|
||
totalChanges bool
|
||
pageCount int
|
||
list []map[string]any
|
||
details []map[string]any
|
||
}
|
||
|
||
func fakeIntegritySybServer(t *testing.T, data integrityServerData) *httptest.Server {
|
||
t.Helper()
|
||
for _, row := range data.list {
|
||
if _, ok := row["shopName"]; !ok {
|
||
row["shopName"] = "测试店铺"
|
||
}
|
||
}
|
||
for _, row := range data.details {
|
||
if _, ok := row["shopName"]; !ok {
|
||
row["shopName"] = "测试店铺"
|
||
}
|
||
}
|
||
totalCalls := 0
|
||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/am/stock/listTotal":
|
||
totalCalls++
|
||
total := data.total
|
||
if data.totalChanges && totalCalls > 1 {
|
||
total = data.afterTotal
|
||
}
|
||
writeEnvelope(t, w, true, "ok", total, nil)
|
||
case "/am/stock/list":
|
||
writeEnvelope(t, w, true, "ok", map[string]any{
|
||
"list": data.list, "total": data.pageCount,
|
||
}, nil)
|
||
case "/am/stock/detail/listByStock":
|
||
writeEnvelope(t, w, true, "ok", map[string]any{"list": data.details}, 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 := newTestDB(t)
|
||
now := model.NowISO()
|
||
if err := repository.CreateUser(db, model.User{
|
||
UserID: "SYB-TEST-ADMIN", Username: "syb-test-admin", PasswordHash: "test",
|
||
Role: model.RoleAdmin, Status: model.UserActive, PasswordChangedAt: now, CreatedAt: now, UpdatedAt: now,
|
||
}); err != nil {
|
||
t.Fatalf("准备同步测试管理员失败: %v", err)
|
||
}
|
||
if err := repository.InsertShop(db, model.Shop{
|
||
ShopID: "SYB-TEST-SHOP", DisplayName: "测试店铺", NormalizedName: "测试店铺", Enabled: true,
|
||
CreatedByUserID: "SYB-TEST-ADMIN", CreatedAt: now, UpdatedAt: now,
|
||
}); err != nil {
|
||
t.Fatalf("准备同步测试业务店铺失败: %v", err)
|
||
}
|
||
if err := repository.InsertShopAlias(db, model.ShopChannelAlias{AliasID: "SYB-TEST-ALIAS",
|
||
ShopID: "SYB-TEST-SHOP", Channel: "syb", AliasName: "测试店铺", NormalizedAlias: "测试店铺",
|
||
Enabled: true, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||
t.Fatalf("准备同步测试渠道名称失败: %v", err)
|
||
}
|
||
return db
|
||
}
|
||
|
||
func TestRunSybSync_没有启用店铺时不请求顺运宝(t *testing.T) {
|
||
db := newSyncTestDB(t)
|
||
if _, err := db.Exec(`UPDATE shop_channel_aliases SET enabled=0 WHERE channel='syb'`); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
requests := 0
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
requests++
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
}))
|
||
defer srv.Close()
|
||
client, _ := syb.New(srv.URL)
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100}, time.Now(),
|
||
SybSyncOptions{From: "2026-08-11", To: "2026-08-11"})
|
||
if report.Err == nil || !strings.Contains(report.Err.Error(), "没有启用") {
|
||
t.Fatalf("实际错误: %v", report.Err)
|
||
}
|
||
if requests != 0 {
|
||
t.Fatalf("空白名单不应请求顺运宝,实际 %d 次", requests)
|
||
}
|
||
if report.CursorAdvanced {
|
||
t.Fatal("空白名单不得推进游标")
|
||
}
|
||
}
|
||
|
||
func TestSybShopAllowed_明细店铺覆盖列表后重新拦截(t *testing.T) {
|
||
allowed := map[string]string{"测试店铺": "SHOP-1"}
|
||
if shopID, ok := sybShopID(allowed, map[string]any{"shopName": " 测试店铺 "}, nil); !ok || shopID != "SHOP-1" {
|
||
t.Fatal("应忽略允许店铺名称首尾空白")
|
||
}
|
||
if _, ok := sybShopID(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": "其他店铺"}); ok {
|
||
t.Fatal("明细店铺变化后必须重新拦截")
|
||
}
|
||
if _, ok := sybShopID(allowed, map[string]any{"shopName": "测试店铺"}, map[string]any{"shopName": " "}); ok {
|
||
t.Fatal("明细店铺变为空值时必须拦截")
|
||
}
|
||
}
|
||
|
||
func TestRunSybSync_只请求并写入允许店铺(t *testing.T) {
|
||
db := newSyncTestDB(t)
|
||
detailIDs := []int64{}
|
||
stocks := []fakeStock{
|
||
{ID: 1, Code: "A", ShopName: " 测试店铺 ", Details: []fakeDetail{{ID: 11, ProductID: 111, ProductQty: 1}}},
|
||
{ID: 2, Code: "B", ShopName: "其他店铺", Details: []fakeDetail{{ID: 22, ProductID: 222, ProductQty: 1}}},
|
||
}
|
||
base := fakeSybServer(t, stocks, 0)
|
||
defer base.Close()
|
||
target, _ := url.Parse(base.URL)
|
||
reverseProxy := httputil.NewSingleHostReverseProxy(target)
|
||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path == "/am/stock/detail/listByStock" {
|
||
var body struct {
|
||
IDs []int64 `json:"ids"`
|
||
}
|
||
data, _ := io.ReadAll(r.Body)
|
||
_ = json.Unmarshal(data, &body)
|
||
detailIDs = append(detailIDs, body.IDs...)
|
||
r.Body = io.NopCloser(strings.NewReader(string(data)))
|
||
}
|
||
reverseProxy.ServeHTTP(w, r)
|
||
}))
|
||
defer proxy.Close()
|
||
client, _ := syb.New(proxy.URL)
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: proxy.URL, PageSize: 20, MaxMatches: 100},
|
||
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-07-28", To: "2026-07-28"})
|
||
if report.Err != nil {
|
||
t.Fatal(report.Err)
|
||
}
|
||
if report.StockCount != 2 || report.AcceptedCount != 1 || report.ShopSkipped != 1 || report.Created != 1 {
|
||
t.Fatalf("店铺统计不正确: %+v", report)
|
||
}
|
||
if len(detailIDs) != 1 || detailIDs[0] != 1 {
|
||
t.Fatalf("明细请求应只有允许店铺,实际 %v", detailIDs)
|
||
}
|
||
}
|
||
|
||
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 := RunSybSyncWithOptions(context.Background(), db, client, cfg, now.Add(time.Hour), SybSyncOptions{
|
||
From: "2026-07-28",
|
||
To: "2026-07-28",
|
||
})
|
||
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)
|
||
}
|
||
var shopName string
|
||
if err := db.QueryRow(`SELECT shop_name FROM syb_orders WHERE syb_id = ?`,
|
||
"145306175").Scan(&shopName); err != nil || shopName != "测试店铺" {
|
||
t.Fatalf("同步应写入货运单店铺名,实际 %q err=%v", shopName, err)
|
||
}
|
||
|
||
// 收件人信息不应该出现在 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 TestRunSybSyncWithOptions_历史补拉不推进游标(t *testing.T) {
|
||
srv := fakeSybServer(t, nil, 0)
|
||
defer srv.Close()
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
const original = "2026-07-20T06:30:00Z"
|
||
if err := repository.SetSybLastSyncedAt(db, original); err != nil {
|
||
t.Fatalf("准备同步游标失败: %v", err)
|
||
}
|
||
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"},
|
||
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-07-01", To: "2026-07-10"})
|
||
if report.Err != nil {
|
||
t.Fatalf("历史补拉失败: %v", report.Err)
|
||
}
|
||
if !report.Specified || !strings.HasPrefix(report.Summary(), "同步完成") {
|
||
t.Fatalf("明确日期范围的报告不正确:%+v / %s", report, report.Summary())
|
||
}
|
||
at, _, err := repository.GetSybLastSyncedAt(db)
|
||
if err != nil {
|
||
t.Fatalf("读取同步游标失败: %v", err)
|
||
}
|
||
if at != original {
|
||
t.Fatalf("局部历史补拉不应推进游标,原值 %q,实际 %q", original, at)
|
||
}
|
||
}
|
||
|
||
func TestRunSybSyncWithOptions_完整覆盖自动范围才推进游标(t *testing.T) {
|
||
srv := fakeSybServer(t, nil, 0)
|
||
defer srv.Close()
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
const original = "2026-07-20T06:30:00Z" // UTC+8 仍是 7 月 20 日
|
||
if err := repository.SetSybLastSyncedAt(db, original); err != nil {
|
||
t.Fatalf("准备同步游标失败: %v", err)
|
||
}
|
||
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500, SyncFrom: "2026-07-01"},
|
||
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-07-20", To: "2026-07-28"})
|
||
if report.Err != nil {
|
||
t.Fatalf("完整范围同步失败: %v", report.Err)
|
||
}
|
||
at, _, err := repository.GetSybLastSyncedAt(db)
|
||
if err != nil {
|
||
t.Fatalf("读取同步游标失败: %v", err)
|
||
}
|
||
if at != "2026-07-27T16:00:00Z" || !report.CursorAdvanced {
|
||
t.Fatalf("连续覆盖后应该把游标推进到所选结束日,实际 %q,报告 %+v", at, report)
|
||
}
|
||
}
|
||
|
||
func TestRunSybSyncWithOptions_跳过缺口不推进游标(t *testing.T) {
|
||
srv := fakeSybServer(t, nil, 0)
|
||
defer srv.Close()
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
const original = "2026-07-01T00:00:00Z"
|
||
if err := repository.SetSybLastSyncedAt(db, original); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 500},
|
||
time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-07-05", To: "2026-07-10"})
|
||
if report.Err != nil {
|
||
t.Fatalf("同步失败: %v", report.Err)
|
||
}
|
||
at, _, err := repository.GetSybLastSyncedAt(db)
|
||
if err != nil || at != original || report.CursorAdvanced {
|
||
t.Fatalf("跳过缺口不应推进游标: at=%q report=%+v err=%v", at, report, err)
|
||
}
|
||
}
|
||
|
||
func TestSybSyncHistory_服务层创建失败记录并展示(t *testing.T) {
|
||
db := newSyncTestDB(t)
|
||
actor := model.User{
|
||
UserID: "USR-HISTORY", Username: "buyer", PasswordHash: "test-hash",
|
||
Role: model.RolePurchaser, Status: model.UserActive,
|
||
PasswordChangedAt: "2026-08-09T00:00:00Z", CreatedAt: "2026-08-09T00:00:00Z",
|
||
UpdatedAt: "2026-08-09T00:00:00Z",
|
||
}
|
||
if err := repository.CreateInitialAdmin(db, actor); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
runID, err := CreateSybSyncRun(db, &actor,
|
||
SybSyncOptions{From: "2026-08-07", To: "2026-08-09"},
|
||
time.Date(2026, 8, 9, 1, 0, 0, 0, time.UTC))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := FinishSybSyncRun(db, runID, SyncReport{
|
||
From: "2026-08-07", To: "2026-08-09", Err: fmt.Errorf("测试失败"),
|
||
FinishedAt: time.Date(2026, 8, 9, 1, 1, 0, 0, time.UTC),
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
history, err := ListSybSyncHistory(db, 1)
|
||
if err != nil || history.Total != 1 || len(history.Rows) != 1 {
|
||
t.Fatalf("读取记录失败: history=%+v err=%v", history, err)
|
||
}
|
||
row := history.Rows[0]
|
||
if row.Username != "buyer" || row.StatusText != "失败" || row.ErrorMessage != "测试失败" || row.CursorText != "未推进" {
|
||
t.Fatalf("同步记录视图不正确: %+v", row)
|
||
}
|
||
}
|
||
|
||
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) {
|
||
listCalls := 0
|
||
detailCalls := 0
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/am/stock/listTotal":
|
||
writeEnvelope(t, w, true, "ok", 2, nil)
|
||
case "/am/stock/list":
|
||
listCalls++
|
||
writeEnvelope(t, w, true, "ok", map[string]any{"list": []any{}, "total": 2}, nil)
|
||
case "/am/stock/detail/listByStock":
|
||
detailCalls++
|
||
writeEnvelope(t, w, true, "ok", map[string]any{"list": []any{}}, nil)
|
||
default:
|
||
w.WriteHeader(http.StatusNotFound)
|
||
}
|
||
}))
|
||
defer srv.Close()
|
||
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 3, SyncFrom: "2026-07-27"},
|
||
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-07-27", To: "2026-07-28"})
|
||
if report.Err == nil || !strings.Contains(report.Err.Error(), "超过单次同步上限") {
|
||
t.Fatalf("两天合计 4 张超过上限 3 应该失败,实际: %v", report.Err)
|
||
}
|
||
if listCalls != 0 || detailCalls != 0 {
|
||
t.Fatalf("容量预检完成前不应拉列表或明细: list=%d detail=%d", listCalls, detailCalls)
|
||
}
|
||
if _, found, _ := repository.GetSybLastSyncedAt(db); found {
|
||
t.Fatal("容量预检失败不得推进游标")
|
||
}
|
||
}
|
||
|
||
func TestRunSybSync_默认上限允许同步超过五百张(t *testing.T) {
|
||
const stockCount = 501
|
||
stocks := make([]fakeStock, 0, stockCount)
|
||
for i := 1; i <= stockCount; i++ {
|
||
id := int64(i)
|
||
stocks = append(stocks, fakeStock{
|
||
ID: id, Code: "ORDER", Details: []fakeDetail{{
|
||
ID: id, ProductID: 100000 + id, ProductQty: 1, ProductPrice: 1,
|
||
}},
|
||
})
|
||
}
|
||
srv := fakeSybServer(t, stocks, 0)
|
||
defer srv.Close()
|
||
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, SyncFrom: "2026-07-28"},
|
||
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-07-28", To: "2026-07-28"})
|
||
if report.Err != nil {
|
||
t.Fatalf("默认上限应该允许 501 张货运单正常同步,实际失败: %v", report.Err)
|
||
}
|
||
if report.StockCount != stockCount || report.Created != stockCount {
|
||
t.Fatalf("同步统计错误: StockCount=%d Created=%d", report.StockCount, report.Created)
|
||
}
|
||
}
|
||
|
||
func TestRunSybSync_跨日范围按天同步并汇总(t *testing.T) {
|
||
srv := fakeSybServer(t, []fakeStock{
|
||
{ID: 1, Code: "DAY-1", Created: "2026-07-27", Details: []fakeDetail{{ID: 11, ProductID: 111, ProductQty: 1}}},
|
||
{ID: 2, Code: "DAY-2", Created: "2026-07-28", Details: []fakeDetail{{ID: 22, ProductID: 222, ProductQty: 1}}},
|
||
}, 0)
|
||
defer srv.Close()
|
||
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100, SyncFrom: "2026-07-27"},
|
||
time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-07-27", To: "2026-07-28"})
|
||
if report.Err != nil {
|
||
t.Fatalf("跨日同步失败: %v", report.Err)
|
||
}
|
||
if report.StockCount != 2 || report.Created != 2 {
|
||
t.Fatalf("跨日汇总错误: StockCount=%d Created=%d", report.StockCount, report.Created)
|
||
}
|
||
}
|
||
|
||
type growingTodayServerState struct {
|
||
historyListCalls int
|
||
todayListCalls int
|
||
currentToday int
|
||
}
|
||
|
||
// fakeGrowingTodaySybServer 模拟“历史日稳定、今天在每次分页时新增一张单”。
|
||
// growAttempts=2 表示前两次漂移、第三次稳定;=3 表示三次都漂移。
|
||
func fakeGrowingTodaySybServer(t *testing.T, today string, growAttempts int) (*httptest.Server, *growingTodayServerState) {
|
||
t.Helper()
|
||
state := &growingTodayServerState{currentToday: 1}
|
||
requestDate := func(body map[string]any) string {
|
||
queries, ok := body["queries"].([]any)
|
||
if !ok || len(queries) == 0 {
|
||
t.Fatalf("同步请求缺少 queries: %#v", body)
|
||
}
|
||
query, ok := queries[0].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("同步请求日期条件格式错误: %#v", queries[0])
|
||
}
|
||
rangeText, _ := query["dvalue"].(string)
|
||
return strings.SplitN(rangeText, ",", 2)[0]
|
||
}
|
||
listRows := func(count int, idOffset int64) []map[string]any {
|
||
rows := make([]map[string]any, 0, count)
|
||
for i := 1; i <= count; i++ {
|
||
id := idOffset + int64(i)
|
||
rows = append(rows, map[string]any{"id": id, "code": fmt.Sprintf("ORDER-%d", id), "shopName": "测试店铺"})
|
||
}
|
||
return rows
|
||
}
|
||
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/am/stock/listTotal":
|
||
var body map[string]any
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
t.Fatalf("解析 listTotal 请求失败: %v", err)
|
||
}
|
||
if requestDate(body) == today {
|
||
writeEnvelope(t, w, true, "ok", state.currentToday, nil)
|
||
} else {
|
||
writeEnvelope(t, w, true, "ok", 1, nil)
|
||
}
|
||
case "/am/stock/list":
|
||
var body map[string]any
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
t.Fatalf("解析 list 请求失败: %v", err)
|
||
}
|
||
if requestDate(body) == today {
|
||
state.todayListCalls++
|
||
if state.todayListCalls <= growAttempts {
|
||
state.currentToday++
|
||
}
|
||
rows := listRows(state.currentToday, 0)
|
||
writeEnvelope(t, w, true, "ok", map[string]any{"list": rows, "total": len(rows)}, nil)
|
||
} else {
|
||
state.historyListCalls++
|
||
rows := listRows(1, 100)
|
||
writeEnvelope(t, w, true, "ok", map[string]any{"list": rows, "total": len(rows)}, nil)
|
||
}
|
||
case "/am/stock/detail/listByStock":
|
||
var body struct {
|
||
IDs []int64 `json:"ids"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
t.Fatalf("解析明细请求失败: %v", err)
|
||
}
|
||
list := make([]map[string]any, 0, len(body.IDs))
|
||
for _, id := range body.IDs {
|
||
list = append(list, map[string]any{
|
||
"id": id, "code": fmt.Sprintf("ORDER-%d", id), "shopName": "测试店铺",
|
||
"details": []map[string]any{{
|
||
"id": id + 1000, "productId": id + 10000, "productQty": 1,
|
||
}},
|
||
})
|
||
}
|
||
writeEnvelope(t, w, true, "ok", map[string]any{"list": list}, nil)
|
||
default:
|
||
t.Errorf("测试假服务端没有实现这个路径: %s", r.URL.Path)
|
||
w.WriteHeader(http.StatusNotFound)
|
||
}
|
||
}))
|
||
return srv, state
|
||
}
|
||
|
||
func TestRunSybSync_今天前两次漂移第三次稳定只重试今天(t *testing.T) {
|
||
const today = "2026-08-12"
|
||
srv, state := fakeGrowingTodaySybServer(t, today, 2)
|
||
defer srv.Close()
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100},
|
||
time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-08-11", To: today})
|
||
if report.Err != nil {
|
||
t.Fatalf("第三次稳定后应该成功: %v", report.Err)
|
||
}
|
||
if state.historyListCalls != 1 || state.todayListCalls != 3 {
|
||
t.Fatalf("只应重试今天: history=%d today=%d", state.historyListCalls, state.todayListCalls)
|
||
}
|
||
if report.StockCount != 4 || report.Created != 4 || !report.CursorAdvanced {
|
||
t.Fatalf("稳定后的统计或游标错误: %+v", report)
|
||
}
|
||
}
|
||
|
||
func TestRunSybSync_今天连续三次漂移保存最后一次完整明细但不推进游标(t *testing.T) {
|
||
const today = "2026-08-12"
|
||
srv, state := fakeGrowingTodaySybServer(t, today, 3)
|
||
defer srv.Close()
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100},
|
||
time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-08-11", To: today})
|
||
if report.Err == nil || !strings.Contains(report.Err.Error(), "连续 3 次") ||
|
||
!strings.Contains(report.Err.Error(), "下次同步将继续覆盖当天") {
|
||
t.Fatalf("持续漂移应该返回可操作提示,实际: %v", report.Err)
|
||
}
|
||
if state.historyListCalls != 1 || state.todayListCalls != 3 {
|
||
t.Fatalf("只应重试今天: history=%d today=%d", state.historyListCalls, state.todayListCalls)
|
||
}
|
||
if report.StockCount != 5 || report.Created != 5 || report.CursorAdvanced {
|
||
t.Fatalf("应保存历史 1 张和最后一次当天 4 张,但不推进游标: %+v", report)
|
||
}
|
||
if total, err := repository.CountSybOrdersTotal(db); err != nil || total != 5 {
|
||
t.Fatalf("已取得的完整明细应落库: total=%d err=%v", total, err)
|
||
}
|
||
if _, found, err := repository.GetSybLastSyncedAt(db); err != nil || found {
|
||
t.Fatalf("持续漂移不得建立或推进游标: found=%v err=%v", found, err)
|
||
}
|
||
}
|
||
|
||
func TestRunSybSync_今天重试增长后仍受MaxMatches限制(t *testing.T) {
|
||
const today = "2026-08-12"
|
||
srv, state := fakeGrowingTodaySybServer(t, today, 3)
|
||
defer srv.Close()
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 3},
|
||
time.Date(2026, 8, 12, 8, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-08-11", To: today})
|
||
if report.Err == nil || !strings.Contains(report.Err.Error(), "超过单次同步上限") {
|
||
t.Fatalf("今天增长突破上限时应该停止,实际: %v", report.Err)
|
||
}
|
||
if state.historyListCalls != 1 || state.todayListCalls != 2 {
|
||
t.Fatalf("超限后不应继续重试: history=%d today=%d", state.historyListCalls, state.todayListCalls)
|
||
}
|
||
if total, err := repository.CountSybOrdersTotal(db); err != nil || total != 1 {
|
||
t.Fatalf("只应保留超限前已完成的历史数据: total=%d err=%v", total, err)
|
||
}
|
||
if report.CursorAdvanced {
|
||
t.Fatal("今天增长超限不得推进游标")
|
||
}
|
||
}
|
||
|
||
func TestValidateDetailBatch_必须与请求货运单一一对应(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
details []syb.StockDetail
|
||
wantErr string
|
||
}{
|
||
{name: "完整", details: []syb.StockDetail{
|
||
{ID: 1, Details: []syb.DetailItem{{ID: 11}}},
|
||
{ID: 2, Details: []syb.DetailItem{{ID: 22}}},
|
||
}},
|
||
{name: "缺失", details: []syb.StockDetail{{ID: 1, Details: []syb.DetailItem{{ID: 11}}}}, wantErr: "缺少"},
|
||
{name: "重复", details: []syb.StockDetail{
|
||
{ID: 1, Details: []syb.DetailItem{{ID: 11}}},
|
||
{ID: 1, Details: []syb.DetailItem{{ID: 12}}},
|
||
}, wantErr: "重复"},
|
||
{name: "意外ID", details: []syb.StockDetail{
|
||
{ID: 1, Details: []syb.DetailItem{{ID: 11}}},
|
||
{ID: 3, Details: []syb.DetailItem{{ID: 33}}},
|
||
}, wantErr: "未请求"},
|
||
{name: "空明细", details: []syb.StockDetail{
|
||
{ID: 1, Details: []syb.DetailItem{{ID: 11}}}, {ID: 2},
|
||
}, wantErr: "没有返回商品明细"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
err := validateDetailBatch([]int64{1, 2}, tc.details)
|
||
if tc.wantErr == "" && err != nil {
|
||
t.Fatalf("完整响应不应该报错: %v", err)
|
||
}
|
||
if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) {
|
||
t.Fatalf("错误应包含 %q,实际 %v", tc.wantErr, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRunSybSync_列表或明细不完整时不推进游标(t *testing.T) {
|
||
completeList := []map[string]any{{"id": 1, "code": "A"}, {"id": 2, "code": "B"}}
|
||
completeDetails := []map[string]any{
|
||
{"id": 1, "code": "A", "details": []map[string]any{{"id": 11, "productId": 111, "productQty": 1}}},
|
||
{"id": 2, "code": "B", "details": []map[string]any{{"id": 22, "productId": 222, "productQty": 1}}},
|
||
}
|
||
cases := []struct {
|
||
name string
|
||
data integrityServerData
|
||
wantErr string
|
||
}{
|
||
{name: "页内报告条数与实际列表不符", data: integrityServerData{total: 2, pageCount: 1, list: completeList}, wantErr: "当前页条数"},
|
||
{name: "当前页短于预期", data: integrityServerData{total: 2, pageCount: 1,
|
||
list: []map[string]any{{"id": 1, "code": "A"}}}, wantErr: "第 1 页不完整"},
|
||
{name: "分页前后总数变化", data: integrityServerData{total: 2, afterTotal: 3, totalChanges: true,
|
||
pageCount: 2, list: completeList}, wantErr: "分页期间"},
|
||
{name: "列表存在重复ID", data: integrityServerData{total: 2, pageCount: 2,
|
||
list: []map[string]any{{"id": 1, "code": "A"}, {"id": 1, "code": "A"}}}, wantErr: "列表不完整"},
|
||
{name: "明细缺少货运单", data: integrityServerData{total: 2, pageCount: 2,
|
||
list: completeList, details: completeDetails[:1]}, wantErr: "明细不完整"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
srv := fakeIntegritySybServer(t, tc.data)
|
||
defer srv.Close()
|
||
db := newSyncTestDB(t)
|
||
client, _ := syb.New(srv.URL)
|
||
report := RunSybSyncWithOptions(context.Background(), db, client,
|
||
config.SybConfig{BaseURL: srv.URL, PageSize: 20, MaxMatches: 100, SyncFrom: "2026-07-28"},
|
||
time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC),
|
||
SybSyncOptions{From: "2026-07-28", To: "2026-07-28"})
|
||
if report.Err == nil || !strings.Contains(report.Err.Error(), tc.wantErr) {
|
||
t.Fatalf("错误应包含 %q,实际 %v", tc.wantErr, report.Err)
|
||
}
|
||
if _, found, err := repository.GetSybLastSyncedAt(db); err != nil || found {
|
||
t.Fatalf("不完整响应不得推进游标: found=%v err=%v", found, err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|