Files
cmautobuy/admin/repository/syb_test.go
T

453 lines
17 KiB
Go

package repository
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"os"
"strings"
"testing"
"cmautobuy/admin/config"
"cmautobuy/admin/model"
"cmautobuy/admin/spec"
)
func newSybTestDB(t *testing.T) *sql.DB {
t.Helper()
if os.Getenv("CMAUTOBUY_MYSQL_TEST") != "1" {
t.Skip("未启用真实 MySQL 8 集成测试")
}
cfg, err := config.LoadDatabaseFromEnv()
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(cfg.Name, "_test") {
t.Fatalf("MySQL 测试基础库名必须以 _test 结尾")
}
adminDB, err := OpenMySQL(cfg)
if err != nil {
t.Fatal(err)
}
random := make([]byte, 8)
if _, err := rand.Read(random); err != nil {
t.Fatal(err)
}
databaseName := cfg.Name + "_" + hex.EncodeToString(random)
quotedName := "`" + strings.ReplaceAll(databaseName, "`", "``") + "`"
if _, err := adminDB.Exec("CREATE DATABASE " + quotedName + " CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"); err != nil {
adminDB.Close()
t.Fatal(err)
}
cfg.Name = databaseName
db, err := OpenMySQL(cfg)
if err != nil {
adminDB.Exec("DROP DATABASE " + quotedName)
adminDB.Close()
t.Fatal(err)
}
if err := MigrateMySQL(db); err != nil {
db.Close()
adminDB.Exec("DROP DATABASE " + quotedName)
adminDB.Close()
t.Fatal(err)
}
t.Cleanup(func() {
db.Close()
if _, err := adminDB.Exec("DROP DATABASE " + quotedName); err != nil {
t.Errorf("清理隔离 MySQL 测试库失败: %v", err)
}
adminDB.Close()
})
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_只更新Syb骨架标题(t *testing.T) {
db := newSybTestDB(t)
now := model.NowISO()
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('SYB-P','旧骨架','syb',?,?),('API-P','正式标题','api',?,?)`, now, now, now, now); err != nil {
t.Fatal(err)
}
for _, o := range []model.SybOrder{{SybID: "S1", OrderNo: "O1", Title: "新骨架", ProductSpec: "黑色,M", ShopeeGoodsID: "SYB-P", Quantity: 1, PriceTwdCent: 100, SybData: "{}"}, {SybID: "S2", OrderNo: "O2", Title: "顺运宝标题", ProductSpec: "白色,L", ShopeeGoodsID: "API-P", Quantity: 1, PriceTwdCent: 200, SybData: "{}"}} {
if _, err := UpsertSybOrder(db, o); err != nil {
t.Fatal(err)
}
}
var sybTitle, apiTitle string
_ = db.QueryRow(`SELECT title FROM shopee_products WHERE goods_id='SYB-P'`).Scan(&sybTitle)
_ = db.QueryRow(`SELECT title FROM shopee_products WHERE goods_id='API-P'`).Scan(&apiTitle)
if sybTitle != "新骨架" || apiTitle != "正式标题" {
t.Fatalf("来源保护失败:syb=%q api=%q", sybTitle, apiTitle)
}
}
func TestUpsertSybOrder_店铺图片按字段来源保护(t *testing.T) {
db := newSybTestDB(t)
order := model.SybOrder{SybID: "META-1", OrderNo: "O-META", Title: "商品", ShopeeGoodsID: "SP-META", ProductSpec: "黑色,M", ShopName: "店铺一", ImageURL: "https://example.com/1.jpg", Quantity: 1, SybData: "{}"}
if _, err := UpsertSybOrder(db, order); err != nil {
t.Fatal(err)
}
var shop, image, shopSource, imageSource string
if err := db.QueryRow(`SELECT shopee_shop_name,image_url,shop_name_source,image_source FROM shopee_products WHERE goods_id='SP-META'`).Scan(&shop, &image, &shopSource, &imageSource); err != nil {
t.Fatal(err)
}
if shop != "店铺一" || image != "https://example.com/1.jpg" || shopSource != "syb" || imageSource != "syb" {
t.Fatalf("首次补全不正确:shop=%q image=%q sources=%q/%q", shop, image, shopSource, imageSource)
}
order.ShopName, order.ImageURL = "店铺二", "https://example.com/2.jpg"
if _, err := UpsertSybOrder(db, order); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`UPDATE shopee_products SET image_url='https://example.com/api.jpg',image_source='api',shop_name_is_manual=1 WHERE goods_id='SP-META'`); err != nil {
t.Fatal(err)
}
order.ShopName, order.ImageURL = "不应覆盖的店铺", "https://example.com/3.jpg"
if _, err := UpsertSybOrder(db, order); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT shopee_shop_name,image_url FROM shopee_products WHERE goods_id='SP-META'`).Scan(&shop, &image); err != nil {
t.Fatal(err)
}
if shop != "店铺二" || image != "https://example.com/api.jpg" {
t.Fatalf("字段保护失败:shop=%q image=%q", shop, image)
}
}
func TestUpsertSybShopeeSKU_确定性解析并允许权威来源升级(t *testing.T) {
db := newSybTestDB(t)
now := model.NowISO()
if _, err := db.Exec(`INSERT INTO shopee_products(goods_id,title,source,created_at,updated_at) VALUES('SP-SKU','商品','syb',?,?)`, now, now); err != nil {
t.Fatal(err)
}
parsed, err := UpsertSybShopeeSKU(db, "SP-SKU", "白色,L【建議50-60公斤】", now)
if err != nil || !parsed {
t.Fatalf("SYB 规格应解析成功:parsed=%v err=%v", parsed, err)
}
if parsed, err := UpsertSybShopeeSKU(db, "SP-SKU", "不明确规格", now); err != nil || parsed {
t.Fatalf("不明确规格不应写入:parsed=%v err=%v", parsed, err)
}
key, _ := spec.SpecKey("白色,L【建議50-60公斤】")
outcome, err := UpsertCatalogShopeeSKU(db, CatalogShopeeSKUInput{RecordID: "API-NEW", ShopeeSKUID: "REAL-1", GoodsID: "SP-SKU", SpecRaw: "白色,L【建議50-60公斤】", SpecKey: key, Color: "象牙白", Size: "L", Advice: "建议50-60公斤", ParseOK: true, Source: "partner", ObservedAt: now, Now: now, UpdatePolicy: "fill_missing"})
if err != nil || outcome != CatalogSKUFilled {
t.Fatalf("权威来源升级失败:outcome=%s err=%v", outcome, err)
}
var count int
var external, color, sourceJSON string
if err := db.QueryRow(`SELECT COUNT(*),MAX(COALESCE(shopee_sku_id,'')),MAX(COALESCE(color,'')),MAX(COALESCE(field_sources,'')) FROM shopee_skus WHERE goods_id='SP-SKU'`).Scan(&count, &external, &color, &sourceJSON); err != nil {
t.Fatal(err)
}
if count != 1 || external != "REAL-1" || color != "象牙白" || !strings.Contains(sourceJSON, `"color":"partner"`) {
t.Fatalf("升级后数据不正确:count=%d external=%q color=%q sources=%s", count, external, color, sourceJSON)
}
}
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 TestUpsertSybOrder_骨架失败时同事务不写订单(t *testing.T) {
db := newSybTestDB(t)
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if _, err := UpsertSybOrder(tx, model.SybOrder{
SybID: "SYB-REJECT", OrderNo: "ORDER-REJECT", Title: "测试",
ShopeeGoodsID: strings.Repeat("G", 192), ProductSpec: "黑色,M", Quantity: 1, SybData: "{}",
}); err == nil {
t.Fatal("骨架失败必须让订单写入失败")
}
tx.Rollback()
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM syb_orders WHERE syb_id='SYB-REJECT'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("失败后不应留下订单 count=%d err=%v", count, err)
}
}
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 createSybSyncTestUser(t *testing.T, db *sql.DB) model.User {
t.Helper()
user := model.User{
UserID: "USR-SYNC", 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 := CreateInitialAdmin(db, user); err != nil {
t.Fatalf("创建同步记录测试用户失败: %v", err)
}
return user
}
func TestSybSyncRun_创建完成并分页读取(t *testing.T) {
db := newSybTestDB(t)
user := createSybSyncTestUser(t, db)
run := model.SybSyncRun{
RunID: "SYB-RUN-1", UserID: user.UserID, DateFrom: "2026-08-07",
DateTo: "2026-08-09", StartedAt: "2026-08-09T01:00:00Z",
}
if err := CreateSybSyncRun(db, run); err != nil {
t.Fatalf("创建同步记录失败: %v", err)
}
if err := FinishSybSyncRun(db, model.SybSyncRun{
RunID: "SYB-RUN-1", Status: model.SybSyncSucceeded,
StockCount: 3, DetailCount: 4, Created: 2, Updated: 2, Skipped: 1,
CursorAdvanced: true, FinishedAt: "2026-08-09T01:02:00Z",
}); err != nil {
t.Fatalf("完成同步记录失败: %v", err)
}
rows, err := ListSybSyncRuns(db, 10, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("读取同步记录失败: rows=%+v err=%v", rows, err)
}
got := rows[0]
if got.Username != "buyer" || got.Status != model.SybSyncSucceeded ||
got.DetailCount != 4 || !got.CursorAdvanced || got.FinishedAt == "" {
t.Fatalf("同步记录字段不正确: %+v", got)
}
if count, err := CountSybSyncRuns(db); err != nil || count != 1 {
t.Fatalf("同步记录总数错误: count=%d err=%v", count, err)
}
}
func TestInterruptRunningSybSyncRuns_只中断未完成记录(t *testing.T) {
db := newSybTestDB(t)
user := createSybSyncTestUser(t, db)
for _, id := range []string{"RUNNING", "DONE"} {
if err := CreateSybSyncRun(db, model.SybSyncRun{
RunID: id, UserID: user.UserID, DateFrom: "2026-08-09", DateTo: "2026-08-09",
StartedAt: "2026-08-09T01:00:00Z",
}); err != nil {
t.Fatal(err)
}
}
if err := FinishSybSyncRun(db, model.SybSyncRun{
RunID: "DONE", Status: model.SybSyncSucceeded, FinishedAt: "2026-08-09T01:01:00Z",
}); err != nil {
t.Fatal(err)
}
affected, err := InterruptRunningSybSyncRuns(db, "2026-08-09T02:00:00Z")
if err != nil || affected != 1 {
t.Fatalf("应该只中断一条 running 记录: affected=%d err=%v", affected, err)
}
rows, _ := ListSybSyncRuns(db, 10, 0)
statuses := map[string]model.SybSyncRunStatus{}
for _, row := range rows {
statuses[row.RunID] = row.Status
}
if statuses["RUNNING"] != model.SybSyncInterrupted || statuses["DONE"] != model.SybSyncSucceeded {
t.Fatalf("中断状态错误: %+v", statuses)
}
}
func TestListSybOrders_多订单精确与店铺组合筛选(t *testing.T) {
db := newSybTestDB(t)
mustUpsert := func(sybID, orderNo, shop, title string) {
if _, err := UpsertSybOrder(db, model.SybOrder{
SybID: sybID, OrderNo: orderNo, ShopName: shop, 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{OrderNos: []string{"260728AAA", "260728BBB"}}, 20, 0)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(rows) != 2 {
t.Fatalf("两个精确订单号应该查到两条,实际 %+v", rows)
}
rows2, err := ListSybOrders(db, SybOrderFilter{OrderNos: []string{"AAA", "牛仔"}}, 20, 0)
if err != nil {
t.Fatalf("查询失败: %v", err)
}
if len(rows2) != 0 {
t.Fatalf("订单号片段和商品标题都不应再命中,实际 %+v", rows2)
}
rows3, err := ListSybOrders(db, SybOrderFilter{Shop: " 服饰 "}, 20, 0)
if err != nil || len(rows3) != 1 || rows3[0].SybID != "SYB-A" || rows3[0].ShopName != "台北服饰旗舰店" {
t.Fatalf("按店铺模糊筛选错误:rows=%+v err=%v", rows3, err)
}
filter := SybOrderFilter{OrderNos: []string{"260728AAA", "260728BBB", "NOT-FOUND"}, Shop: "台南"}
count, err := CountSybOrders(db, filter)
if err != nil || count != 1 {
t.Fatalf("订单号与店铺组合 COUNT 错误:count=%d err=%v", count, err)
}
matched, err := ListMatchedSybOrderNos(db, filter)
if err != nil || len(matched) != 1 || matched[0] != "260728BBB" {
t.Fatalf("当前筛选命中订单号错误:matched=%v err=%v", matched, err)
}
}