Files
cmautobuy/admin/repository/syb_test.go
T
chengmaandClaude Opus 5 5e426cacf6 feat: 顺运宝货运单同步 (#46)
顺运宝模块此前是骨架,「同步」点了提示"待接入"。5195 个蝦皮商品已经
进系统,但货运单(真实订单)一条都没有,后面的规格匹配无从谈起。

按接口契约(docs/admin/08,从 4 份 HAR 还原)实现:配置、登录(界面
手工输验证码)、会话缓存到 SQLite、按日期范围增量同步、落 syb_orders。

shopee_sku_id 绝不被同步覆盖。它是规格匹配的结果,顺运宝那边根本没有
这个值(只给 11 位商品ID,蝦皮規格ID 是 12 位)。同步写进去就是写空,
把人工攒的匹配成果洗掉且不报错。它只出现在 INSERT 列清单里,不在
DO UPDATE SET 里;repository 层和 service 端到端各有一个测试守着。

增量从「上次同步日期当天」重拉,不是第二天。created 筛选粒度是日期而
last_synced_at 精确到秒,从第二天拉会漏掉当天晚些时候创建的单且不报错。
宁可重复拉(upsert 幂等)也不能漏。中途失败不更新 last_synced_at,
否则下次跳过这段区间,漏的单永远补不回来。

日期运算用 UTC+8,不是 UTC。审查时从 HAR 确认 created 是当地时间:
抓包于 2026-07-28T03:31:45Z(= 11:31 UTC+8),同一响应里 created 是
"2026-07-28 10:37:59";若它是 UTC 则等于 18:37 UTC+8,比抓包晚 7 小时,
订单创建于未来,不成立。用 UTC 算会在本地 00:00-08:00 把"今天"算成昨天,
当天早晨的单这轮拉不到。用 time.FixedZone 写死,不用 LoadLocation——
那要读系统 tzdata,Windows 默认没有,打包成 exe 会失败。

金额一律取 detail/listByStock 的值:08 §5.1 实测同一响应里 amtOrder
在列表接口是分、escrowAmount 却不是,单位不统一,取错差 100 倍。

迁移 v5 纯追加(syb_session、syb_sync_state、syb_orders.product_spec),
v1-v4 逐字未动,CheckSchema 覆盖新表新列。

会话有效性判断把「网络故障」和「明确未登录」的分类集中在 Client.do()
一处——网络抖一下就判定登出的话,验证码会弹个不停,还会丢掉有效会话。

测试全部用 httptest 假服务端,不打真实站点。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 11:49:13 +08:00

222 lines
7.2 KiB
Go

package repository
import (
"database/sql"
"testing"
"cmautobuy/admin/model"
)
func newSybTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := Open(t.TempDir())
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := Migrate(db); err != nil {
t.Fatalf("迁移失败: %v", err)
}
return db
}
func TestUpsertSybOrder_已有的ShopeeSKUID同步后仍在(t *testing.T) {
// 这是工单 #46 最要紧的一条:shopee_sku_id 是人工规格匹配的结果,
// 顺运宝同步绝不能把它覆盖成空值——覆盖了不会报错,等到建采购任务
// 才会发现匹配成果被洗掉,那时已经找不回来了。
db := newSybTestDB(t)
first := model.SybOrder{
SybID: "SYB-1", OrderNo: "ORDER-1", Title: "初始标题",
ProductSpec: "黑色,M", ShopeeGoodsID: "50209124255",
Quantity: 1, PriceTwdCent: 23900, ImageURL: "https://x/img.jpg",
SybData: `{"stock":{}}`,
}
created, err := UpsertSybOrder(db, first)
if err != nil {
t.Fatalf("首次写入失败: %v", err)
}
if !created {
t.Fatal("首次写入应该是新建")
}
// 模拟操作员在界面上完成了规格匹配,手工把 shopee_sku_id 填上。
if _, err := db.Exec(`UPDATE syb_orders SET shopee_sku_id = ? WHERE syb_id = ?`,
"TEST-SKU-123", "SYB-1"); err != nil {
t.Fatalf("模拟人工匹配失败: %v", err)
}
// 再次同步:标题、数量、价格都变了(模拟顺运宝那边数据更新),
// 但 upsert 调用方不会传 shopee_sku_id 的新值(顺运宝根本没有这个字段)。
second := model.SybOrder{
SybID: "SYB-1", OrderNo: "ORDER-1", Title: "更新后的标题",
ProductSpec: "黑色,M", ShopeeGoodsID: "50209124255",
Quantity: 3, PriceTwdCent: 25900, ImageURL: "https://x/img2.jpg",
SybData: `{"stock":{"updated":true}}`,
// 注意:这里刻意不设置 ShopeeSKUID(零值,即空字符串),
// 模拟"同步流程从来不知道匹配结果,只管顺运宝返回的字段"。
}
created2, err := UpsertSybOrder(db, second)
if err != nil {
t.Fatalf("二次写入失败: %v", err)
}
if created2 {
t.Fatal("二次写入应该是更新,不是新建")
}
var skuID, title string
var qty int
if err := db.QueryRow(`SELECT shopee_sku_id, title, quantity FROM syb_orders WHERE syb_id = ?`,
"SYB-1").Scan(&skuID, &title, &qty); err != nil {
t.Fatalf("查询失败: %v", err)
}
if skuID != "TEST-SKU-123" {
t.Fatalf("shopee_sku_id 应该还是人工匹配的 TEST-SKU-123,实际 %q"+
"——同步把匹配成果覆盖掉了,这是本工单最不能接受的问题", skuID)
}
// 其余允许覆盖的字段应该已经更新,证明这不是"upsert 整个没生效"的假通过。
if title != "更新后的标题" || qty != 3 {
t.Errorf("title/quantity 应该被同步更新,实际 title=%q quantity=%d", title, qty)
}
}
func TestUpsertSybOrder_ShopeeGoodsID会被同步更新(t *testing.T) {
db := newSybTestDB(t)
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: "SYB-2", OrderNo: "ORDER-2", ShopeeGoodsID: "11111111111", Quantity: 1,
SybData: "{}",
}); err != nil {
t.Fatalf("首次写入失败: %v", err)
}
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: "SYB-2", OrderNo: "ORDER-2", ShopeeGoodsID: "22222222222", Quantity: 1,
SybData: "{}",
}); err != nil {
t.Fatalf("二次写入失败: %v", err)
}
var goodsID string
if err := db.QueryRow(`SELECT shopee_goods_id FROM syb_orders WHERE syb_id = ?`,
"SYB-2").Scan(&goodsID); err != nil {
t.Fatalf("查询失败: %v", err)
}
if goodsID != "22222222222" {
t.Errorf("shopee_goods_id 应该被同步更新为顺运宝返回的新值,实际 %q", goodsID)
}
}
func TestUpsertSybOrder_重复同步同一订单行数不翻倍(t *testing.T) {
db := newSybTestDB(t)
for i := 0; i < 3; i++ {
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: "SYB-3", OrderNo: "ORDER-3", Quantity: 1, SybData: "{}",
}); err != nil {
t.Fatalf("第 %d 次写入失败: %v", i+1, err)
}
}
n, err := CountSybOrdersTotal(db)
if err != nil {
t.Fatalf("统计失败: %v", err)
}
if n != 1 {
t.Fatalf("重复同步同一个 syb_id 应该只有 1 行,实际 %d 行", n)
}
}
func TestSybSession_保存读取和清除(t *testing.T) {
db := newSybTestDB(t)
if got, err := GetSybSession(db, "tester"); err != nil || got != nil {
t.Fatalf("没有会话时应该返回 (nil, nil),实际 got=%v err=%v", got, err)
}
if err := SaveSybSession(db, "tester", `[{"name":"JSESSIONID","value":"abc"}]`, "2026-08-10T00:00:00Z"); err != nil {
t.Fatalf("保存会话失败: %v", err)
}
got, err := GetSybSession(db, "tester")
if err != nil {
t.Fatalf("读取会话失败: %v", err)
}
if got == nil || got.ExpiresAt != "2026-08-10T00:00:00Z" {
t.Fatalf("读取到的会话不对: %+v", got)
}
// upsert:同一用户名再保存一次应该覆盖,不是新增一行。
if err := SaveSybSession(db, "tester", `[{"name":"JSESSIONID","value":"xyz"}]`, "2026-08-11T00:00:00Z"); err != nil {
t.Fatalf("二次保存会话失败: %v", err)
}
got2, err := GetSybSession(db, "tester")
if err != nil || got2 == nil || got2.ExpiresAt != "2026-08-11T00:00:00Z" {
t.Fatalf("二次保存后应该读到新值: got=%+v err=%v", got2, err)
}
if err := DeleteSybSession(db, "tester"); err != nil {
t.Fatalf("删除会话失败: %v", err)
}
if got3, err := GetSybSession(db, "tester"); err != nil || got3 != nil {
t.Fatalf("删除后应该查不到,实际 got=%v err=%v", got3, err)
}
}
func TestSybSyncState_首次为空之后可更新(t *testing.T) {
db := newSybTestDB(t)
_, found, err := GetSybLastSyncedAt(db)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if found {
t.Fatal("从没同步过时 found 应该是 false")
}
if err := SetSybLastSyncedAt(db, "2026-08-09T14:30:00Z"); err != nil {
t.Fatalf("更新失败: %v", err)
}
at, found, err := GetSybLastSyncedAt(db)
if err != nil || !found || at != "2026-08-09T14:30:00Z" {
t.Fatalf("读取错误: at=%q found=%v err=%v", at, found, err)
}
// 再更新一次,确认是 upsert 而不是报主键冲突。
if err := SetSybLastSyncedAt(db, "2026-08-10T09:00:00Z"); err != nil {
t.Fatalf("二次更新失败: %v", err)
}
at2, _, err := GetSybLastSyncedAt(db)
if err != nil || at2 != "2026-08-10T09:00:00Z" {
t.Fatalf("二次更新后应该读到新值: at2=%q err=%v", at2, err)
}
}
func TestListSybOrders_关键字筛选订单号和标题(t *testing.T) {
db := newSybTestDB(t)
mustUpsert := func(sybID, orderNo, title string) {
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: sybID, OrderNo: orderNo, Title: title, Quantity: 1, SybData: "{}",
}); err != nil {
t.Fatalf("写入 %s 失败: %v", sybID, err)
}
}
mustUpsert("SYB-A", "260728AAA", "纯棉上衣")
mustUpsert("SYB-B", "260728BBB", "牛仔裤")
rows, err := ListSybOrders(db, SybOrderFilter{Keyword: "AAA"}, 20, 0)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(rows) != 1 || rows[0].SybID != "SYB-A" {
t.Fatalf("按订单号筛选应该只查到 SYB-A,实际 %+v", rows)
}
rows2, err := ListSybOrders(db, SybOrderFilter{Keyword: "牛仔"}, 20, 0)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(rows2) != 1 || rows2[0].SybID != "SYB-B" {
t.Fatalf("按标题筛选应该只查到 SYB-B,实际 %+v", rows2)
}
}