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) } }