package service import ( "context" "database/sql" "encoding/json" "fmt" "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-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-07" || 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-08-05T00:00:00Z"); err != nil { t.Fatal(err) } got, err := DefaultSybSyncRange(db, now) if err != nil || got.From != "2026-08-05" || 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-07-01" || got.To != "2026-07-31" || !strings.Contains(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" } 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() 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() return newTestDB(t) } 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) } // 收件人信息不应该出现在 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) } } 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, 28, 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) } }