639 lines
22 KiB
Go
639 lines
22 KiB
Go
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 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-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 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 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 == original || at == "" {
|
||
t.Fatalf("完整覆盖自动范围后应该推进游标,实际仍为 %q", 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)
|
||
}
|
||
}
|