Files
cmautobuy/admin/service/syb_test.go
T

551 lines
18 KiB
Go
Raw Normal View History

2026-08-09 11:49:13 +08:00
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)
}
}